aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/codeql-analysis.yml32
-rw-r--r--bot/__main__.py8
-rw-r--r--bot/cogs/clean.py75
-rw-r--r--bot/cogs/filtering.py61
-rw-r--r--bot/cogs/help_channels.py2
-rw-r--r--bot/cogs/information.py65
-rw-r--r--bot/cogs/moderation/modlog.py4
-rw-r--r--bot/cogs/moderation/scheduler.py41
-rw-r--r--bot/cogs/moderation/utils.py1
-rw-r--r--bot/cogs/site.py5
-rw-r--r--bot/cogs/stats.py3
-rw-r--r--bot/cogs/sync/cog.py4
-rw-r--r--bot/cogs/sync/syncers.py3
-rw-r--r--bot/cogs/utils.py4
-rw-r--r--bot/constants.py1
-rw-r--r--bot/converters.py5
-rw-r--r--bot/resources/tags/modmail.md9
-rw-r--r--bot/utils/redis_cache.py11
-rw-r--r--config-default.yml2
-rw-r--r--docker-compose.yml9
-rw-r--r--tests/bot/cogs/sync/test_cog.py3
-rw-r--r--tests/bot/cogs/sync/test_users.py2
-rw-r--r--tests/bot/cogs/test_information.py12
-rw-r--r--tests/bot/test_converters.py113
-rw-r--r--tests/bot/utils/test_redis_cache.py10
25 files changed, 322 insertions, 163 deletions
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 000000000..8760b35ec
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,32 @@
+name: "Code scanning - action"
+
+on:
+ push:
+ pull_request:
+ schedule:
+ - cron: '0 12 * * *'
+
+jobs:
+ CodeQL-Build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 2
+
+ - run: git checkout HEAD^2
+ if: ${{ github.event_name == 'pull_request' }}
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v1
+ with:
+ languages: python
+
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v1
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v1
diff --git a/bot/__main__.py b/bot/__main__.py
index aa1d1aee8..4e0d4a111 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -3,7 +3,9 @@ import logging
import discord
import sentry_sdk
from discord.ext.commands import when_mentioned_or
+from sentry_sdk.integrations.aiohttp import AioHttpIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
+from sentry_sdk.integrations.redis import RedisIntegration
from bot import constants, patches
from bot.bot import Bot
@@ -15,7 +17,11 @@ sentry_logging = LoggingIntegration(
sentry_sdk.init(
dsn=constants.Bot.sentry_dsn,
- integrations=[sentry_logging]
+ integrations=[
+ sentry_logging,
+ AioHttpIntegration(),
+ RedisIntegration(),
+ ]
)
bot = Bot(
diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py
index b5d9132cb..368d91c85 100644
--- a/bot/cogs/clean.py
+++ b/bot/cogs/clean.py
@@ -1,16 +1,16 @@
import logging
import random
import re
-from typing import Optional
+from typing import Iterable, Optional
from discord import Colour, Embed, Message, TextChannel, User
+from discord.ext import commands
from discord.ext.commands import Cog, Context, group
from bot.bot import Bot
from bot.cogs.moderation import ModLog
from bot.constants import (
- Channels, CleanMessages, Colours, Event,
- Icons, MODERATION_ROLES, NEGATIVE_REPLIES
+ Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES
)
from bot.decorators import with_role
@@ -41,10 +41,10 @@ class Clean(Cog):
self,
amount: int,
ctx: Context,
+ channels: Iterable[TextChannel],
bots_only: bool = False,
user: User = None,
regex: Optional[str] = None,
- channel: Optional[TextChannel] = None
) -> None:
"""A helper function that does the actual message cleaning."""
def predicate_bots_only(message: Message) -> bool:
@@ -110,48 +110,39 @@ class Clean(Cog):
predicate = None # Delete all messages
# Default to using the invoking context's channel
- if not channel:
- channel = ctx.channel
+ if not channels:
+ channels = [ctx.channel]
+
+ # Delete the invocation first
+ self.mod_log.ignore(Event.message_delete, ctx.message.id)
+ await ctx.message.delete()
- # Look through the history and retrieve message data
messages = []
message_ids = []
self.cleaning = True
- invocation_deleted = False
-
- # To account for the invocation message, we index `amount + 1` messages.
- async for message in channel.history(limit=amount + 1):
- # If at any point the cancel command is invoked, we should stop.
- if not self.cleaning:
- return
+ # Find the IDs of the messages to delete. IDs are needed in order to ignore mod log events.
+ for channel in channels:
+ async for message in channel.history(limit=amount):
- # Always start by deleting the invocation
- if not invocation_deleted:
- self.mod_log.ignore(Event.message_delete, message.id)
- await message.delete()
- invocation_deleted = True
- continue
+ # If at any point the cancel command is invoked, we should stop.
+ if not self.cleaning:
+ return
- # If the message passes predicate, let's save it.
- if predicate is None or predicate(message):
- message_ids.append(message.id)
- messages.append(message)
+ # If the message passes predicate, let's save it.
+ if predicate is None or predicate(message):
+ message_ids.append(message.id)
self.cleaning = False
- # We should ignore the ID's we stored, so we don't get mod-log spam.
+ # Now let's delete the actual messages with purge.
self.mod_log.ignore(Event.message_delete, *message_ids)
-
- # Use bulk delete to actually do the cleaning. It's far faster.
- await channel.purge(
- limit=amount,
- check=predicate
- )
+ for channel in channels:
+ messages += await channel.purge(limit=amount, check=predicate)
# Reverse the list to restore chronological order
if messages:
- messages = list(reversed(messages))
+ messages = reversed(messages)
log_url = await self.mod_log.upload_log(messages, ctx.author.id)
else:
# Can't build an embed, nothing to clean!
@@ -163,8 +154,10 @@ class Clean(Cog):
return
# Build the embed and send it
+ target_channels = ", ".join(channel.mention for channel in channels)
+
message = (
- f"**{len(message_ids)}** messages deleted in <#{channel.id}> by **{ctx.author.name}**\n\n"
+ f"**{len(message_ids)}** messages deleted in {target_channels} by **{ctx.author.name}**\n\n"
f"A log of the deleted messages can be found [here]({log_url})."
)
@@ -189,10 +182,10 @@ class Clean(Cog):
ctx: Context,
user: User,
amount: Optional[int] = 10,
- channel: TextChannel = None
+ channels: commands.Greedy[TextChannel] = None
) -> None:
"""Delete messages posted by the provided user, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, user=user, channel=channel)
+ await self._clean_messages(amount, ctx, user=user, channels=channels)
@clean_group.command(name="all", aliases=["everything"])
@with_role(*MODERATION_ROLES)
@@ -200,10 +193,10 @@ class Clean(Cog):
self,
ctx: Context,
amount: Optional[int] = 10,
- channel: TextChannel = None
+ channels: commands.Greedy[TextChannel] = None
) -> None:
"""Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, channel=channel)
+ await self._clean_messages(amount, ctx, channels=channels)
@clean_group.command(name="bots", aliases=["bot"])
@with_role(*MODERATION_ROLES)
@@ -211,10 +204,10 @@ class Clean(Cog):
self,
ctx: Context,
amount: Optional[int] = 10,
- channel: TextChannel = None
+ channels: commands.Greedy[TextChannel] = None
) -> None:
"""Delete all messages posted by a bot, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, bots_only=True, channel=channel)
+ await self._clean_messages(amount, ctx, bots_only=True, channels=channels)
@clean_group.command(name="regex", aliases=["word", "expression"])
@with_role(*MODERATION_ROLES)
@@ -223,10 +216,10 @@ class Clean(Cog):
ctx: Context,
regex: str,
amount: Optional[int] = 10,
- channel: TextChannel = None
+ channels: commands.Greedy[TextChannel] = None
) -> None:
"""Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, regex=regex, channel=channel)
+ await self._clean_messages(amount, ctx, regex=regex, channels=channels)
@clean_group.command(name="stop", aliases=["cancel", "abort"])
@with_role(*MODERATION_ROLES)
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
index 1d9fddb12..4ebc831e1 100644
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -1,6 +1,8 @@
+import asyncio
import logging
import re
-from typing import Optional, Union
+from datetime import datetime, timedelta
+from typing import List, Optional, Union
import discord.errors
from dateutil.relativedelta import relativedelta
@@ -14,6 +16,7 @@ from bot.constants import (
Channels, Colours,
Filter, Icons, URLs
)
+from bot.utils.redis_cache import RedisCache
log = logging.getLogger(__name__)
@@ -40,6 +43,8 @@ TOKEN_WATCHLIST_PATTERNS = [
]
WATCHLIST_PATTERNS = WORD_WATCHLIST_PATTERNS + TOKEN_WATCHLIST_PATTERNS
+DAYS_BETWEEN_ALERTS = 3
+
def expand_spoilers(text: str) -> str:
"""Return a string containing all interpretations of a spoilered message."""
@@ -52,8 +57,12 @@ def expand_spoilers(text: str) -> str:
class Filtering(Cog):
"""Filtering out invites, blacklisting domains, and warning us of certain regular expressions."""
+ # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent
+ name_alerts = RedisCache()
+
def __init__(self, bot: Bot):
self.bot = bot
+ self.name_lock = asyncio.Lock()
staff_mistake_str = "If you believe this was a mistake, please let staff know!"
self.filters = {
@@ -112,6 +121,7 @@ class Filtering(Cog):
async def on_message(self, msg: Message) -> None:
"""Invoke message filter for new messages."""
await self._filter_message(msg)
+ await self.check_bad_words_in_name(msg.author)
@Cog.listener()
async def on_message_edit(self, before: Message, after: Message) -> None:
@@ -126,6 +136,55 @@ class Filtering(Cog):
delta = relativedelta(after.edited_at, before.edited_at).microseconds
await self._filter_message(after, delta)
+ @staticmethod
+ def get_name_matches(name: str) -> List[re.Match]:
+ """Check bad words from passed string (name). Return list of matches."""
+ matches = []
+ for pattern in WATCHLIST_PATTERNS:
+ if match := pattern.search(name):
+ matches.append(match)
+ return matches
+
+ async def check_send_alert(self, member: Member) -> bool:
+ """When there is less than 3 days after last alert, return `False`, otherwise `True`."""
+ if last_alert := await self.name_alerts.get(member.id):
+ last_alert = datetime.utcfromtimestamp(last_alert)
+ if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert:
+ log.trace(f"Last alert was too recent for {member}'s nickname.")
+ return False
+
+ return True
+
+ async def check_bad_words_in_name(self, member: Member) -> None:
+ """Send a mod alert every 3 days if a username still matches a watchlist pattern."""
+ # Use lock to avoid race conditions
+ async with self.name_lock:
+ # Check whether the users display name contains any words in our blacklist
+ matches = self.get_name_matches(member.display_name)
+
+ if not matches or not await self.check_send_alert(member):
+ return
+
+ log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).")
+
+ log_string = (
+ f"**User:** {member.mention} (`{member.id}`)\n"
+ f"**Display Name:** {member.display_name}\n"
+ f"**Bad Matches:** {', '.join(match.group() for match in matches)}"
+ )
+
+ await self.mod_log.send_log_message(
+ icon_url=Icons.token_removed,
+ colour=Colours.soft_red,
+ title="Username filtering alert",
+ text=log_string,
+ channel_id=Channels.mod_alerts,
+ thumbnail=member.avatar_url
+ )
+
+ # Update time when alert sent
+ await self.name_alerts.set(member.id, datetime.utcnow().timestamp())
+
async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None:
"""Filter the input message to see if it violates any of our rules, and then respond accordingly."""
# Should we filter this message?
diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py
index 70cef339a..6ff285c37 100644
--- a/bot/cogs/help_channels.py
+++ b/bot/cogs/help_channels.py
@@ -22,7 +22,7 @@ log = logging.getLogger(__name__)
ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/"
MAX_CHANNELS_PER_CATEGORY = 50
-EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help,)
+EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown)
HELP_CHANNEL_TOPIC = """
This is a Python help channel. You can claim your own help channel in the Python Help: Available category.
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index f0eb3a1ea..f0bd1afdb 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -6,7 +6,8 @@ from collections import Counter, defaultdict
from string import Template
from typing import Any, Mapping, Optional, Union
-from discord import Colour, Embed, Member, Message, Role, Status, utils
+from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils
+from discord.abc import GuildChannel
from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group
from discord.utils import escape_markdown
@@ -26,6 +27,49 @@ class Information(Cog):
def __init__(self, bot: Bot):
self.bot = bot
+ @staticmethod
+ def role_can_read(channel: GuildChannel, role: Role) -> bool:
+ """Return True if `role` can read messages in `channel`."""
+ overwrites = channel.overwrites_for(role)
+ return overwrites.read_messages is True
+
+ def get_staff_channel_count(self, guild: Guild) -> int:
+ """
+ Get the number of channels that are staff-only.
+
+ We need to know two things about a channel:
+ - Does the @everyone role have explicit read deny permissions?
+ - Do staff roles have explicit read allow permissions?
+
+ If the answer to both of these questions is yes, it's a staff channel.
+ """
+ channel_ids = set()
+ for channel in guild.channels:
+ if channel.type is ChannelType.category:
+ continue
+
+ everyone_can_read = self.role_can_read(channel, guild.default_role)
+
+ for role in constants.STAFF_ROLES:
+ role_can_read = self.role_can_read(channel, guild.get_role(role))
+ if role_can_read and not everyone_can_read:
+ channel_ids.add(channel.id)
+ break
+
+ return len(channel_ids)
+
+ @staticmethod
+ def get_channel_type_counts(guild: Guild) -> str:
+ """Return the total amounts of the various types of channels in `guild`."""
+ channel_counter = Counter(c.type for c in guild.channels)
+ channel_type_list = []
+ for channel, count in channel_counter.items():
+ channel_type = str(channel).title()
+ channel_type_list.append(f"{channel_type} channels: {count}")
+
+ channel_type_list = sorted(channel_type_list)
+ return "\n".join(channel_type_list)
+
@with_role(*constants.MODERATION_ROLES)
@command(name="roles")
async def roles_info(self, ctx: Context) -> None:
@@ -102,15 +146,16 @@ class Information(Cog):
roles = len(ctx.guild.roles)
member_count = ctx.guild.member_count
-
- # How many of each type of channel?
- channels = Counter(c.type for c in ctx.guild.channels)
- channel_counts = "".join(sorted(f"{str(ch).title()} channels: {channels[ch]}\n" for ch in channels)).strip()
+ channel_counts = self.get_channel_type_counts(ctx.guild)
# How many of each user status?
statuses = Counter(member.status for member in ctx.guild.members)
embed = Embed(colour=Colour.blurple())
+ # How many staff members and staff channels do we have?
+ staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members)
+ staff_channel_count = self.get_staff_channel_count(ctx.guild)
+
# Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the
# f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting
# without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts
@@ -122,12 +167,16 @@ class Information(Cog):
Voice region: {region}
Features: {features}
- **Counts**
+ **Channel counts**
+ $channel_counts
+ Staff channels: {staff_channel_count}
+
+ **Member counts**
Members: {member_count:,}
+ Staff members: {staff_member_count}
Roles: {roles}
- $channel_counts
- **Members**
+ **Member statuses**
{constants.Emojis.status_online} {statuses[Status.online]:,}
{constants.Emojis.status_idle} {statuses[Status.idle]:,}
{constants.Emojis.status_dnd} {statuses[Status.dnd]:,}
diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py
index 9d28030d9..41472c64c 100644
--- a/bot/cogs/moderation/modlog.py
+++ b/bot/cogs/moderation/modlog.py
@@ -555,6 +555,10 @@ class ModLog(Cog, name="ModLog"):
channel = message.channel
author = message.author
+ # Ignore DMs.
+ if not message.guild:
+ return
+
if message.guild.id != GuildConstant.id or channel.id in GuildConstant.modlog_blacklist:
return
diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py
index f0a3ad1b1..b03d89537 100644
--- a/bot/cogs/moderation/scheduler.py
+++ b/bot/cogs/moderation/scheduler.py
@@ -106,6 +106,27 @@ class InfractionScheduler(Scheduler):
log_content = None
failed = False
+ # DM the user about the infraction if it's not a shadow/hidden infraction.
+ # This needs to happen before we apply the infraction, as the bot cannot
+ # send DMs to user that it doesn't share a guild with. If we were to
+ # apply kick/ban infractions first, this would mean that we'd make it
+ # impossible for us to deliver a DM. See python-discord/bot#982.
+ if not infraction["hidden"]:
+ dm_result = f"{constants.Emojis.failmail} "
+ dm_log_text = "\nDM: **Failed**"
+
+ # Sometimes user is a discord.Object; make it a proper user.
+ try:
+ if not isinstance(user, (discord.Member, discord.User)):
+ user = await self.bot.fetch_user(user.id)
+ except discord.HTTPException as e:
+ 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):
+ dm_result = ":incoming_envelope: "
+ dm_log_text = "\nDM: Sent"
+
if infraction["actor"] == self.bot.user.id:
log.trace(
f"Infraction #{id_} actor is bot; including the reason in the confirmation message."
@@ -150,27 +171,7 @@ class InfractionScheduler(Scheduler):
log.exception(log_msg)
failed = True
- # DM the user about the infraction if it's not a shadow/hidden infraction.
- # Don't send DM when applying failed.
- if not infraction["hidden"] and not failed:
- dm_result = f"{constants.Emojis.failmail} "
- dm_log_text = "\nDM: **Failed**"
-
- # Sometimes user is a discord.Object; make it a proper user.
- try:
- if not isinstance(user, (discord.Member, discord.User)):
- user = await self.bot.fetch_user(user.id)
- except discord.HTTPException as e:
- 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):
- dm_result = ":incoming_envelope: "
- dm_log_text = "\nDM: Sent"
-
if failed:
- dm_log_text = "\nDM: **Canceled**"
- dm_result = f"{constants.Emojis.failmail} "
log.trace(f"Deleted infraction {infraction['id']} from database because applying infraction failed.")
try:
await self.bot.api_client.delete(f"bot/infractions/{id_}")
diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py
index 1b716b2ea..fb55287b6 100644
--- a/bot/cogs/moderation/utils.py
+++ b/bot/cogs/moderation/utils.py
@@ -41,7 +41,6 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:
log.debug("The user being added to the DB is not a Member or User object.")
payload = {
- 'avatar_hash': getattr(user, 'avatar', 0),
'discriminator': int(getattr(user, 'discriminator', 0)),
'id': user.id,
'in_guild': False,
diff --git a/bot/cogs/site.py b/bot/cogs/site.py
index 7fc2a9c34..ac29daa1d 100644
--- a/bot/cogs/site.py
+++ b/bot/cogs/site.py
@@ -33,7 +33,7 @@ class Site(Cog):
embed.colour = Colour.blurple()
embed.description = (
f"[Our official website]({url}) is an open-source community project "
- "created with Python and Flask. It contains information about the server "
+ "created with Python and Django. It contains information about the server "
"itself, lets you sign up for upcoming events, has its own wiki, contains "
"a list of valuable learning resources, and much more."
)
@@ -133,6 +133,9 @@ class Site(Cog):
await ctx.send(f":x: Invalid rule indices: {indices}")
return
+ for rule in rules:
+ self.bot.stats.incr(f"rule_uses.{rule}")
+
final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules)
await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3)
diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py
index 4ebb6423c..d42f55466 100644
--- a/bot/cogs/stats.py
+++ b/bot/cogs/stats.py
@@ -36,7 +36,8 @@ class Stats(Cog):
if message.guild.id != Guild.id:
return
- if message.channel.category.id == Categories.modmail:
+ cat = getattr(message.channel, "category", None)
+ if cat is not None and cat.id == 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/cogs/sync/cog.py b/bot/cogs/sync/cog.py
index 5708be3f4..7cc3726b2 100644
--- a/bot/cogs/sync/cog.py
+++ b/bot/cogs/sync/cog.py
@@ -94,7 +94,6 @@ class Sync(Cog):
the database, the user is added.
"""
packed = {
- 'avatar_hash': member.avatar,
'discriminator': int(member.discriminator),
'id': member.id,
'in_guild': True,
@@ -135,12 +134,11 @@ class Sync(Cog):
@Cog.listener()
async def on_user_update(self, before: User, after: User) -> None:
"""Update the user information in the database if a relevant change is detected."""
- attrs = ("name", "discriminator", "avatar")
+ attrs = ("name", "discriminator")
if any(getattr(before, attr) != getattr(after, attr) for attr in attrs):
updated_information = {
"name": after.name,
"discriminator": int(after.discriminator),
- "avatar_hash": after.avatar,
}
await self.patch_user(after.id, updated_information=updated_information)
diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py
index e55bf27fd..536455668 100644
--- a/bot/cogs/sync/syncers.py
+++ b/bot/cogs/sync/syncers.py
@@ -17,7 +17,7 @@ log = logging.getLogger(__name__)
# These objects are declared as namedtuples because tuples are hashable,
# something that we make use of when diffing site roles against guild roles.
_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position'))
-_User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild'))
+_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild'))
_Diff = namedtuple('Diff', ('created', 'updated', 'deleted'))
@@ -298,7 +298,6 @@ class UserSyncer(Syncer):
id=member.id,
name=member.name,
discriminator=int(member.discriminator),
- avatar_hash=member.avatar,
roles=tuple(sorted(role.id for role in member.roles)),
in_guild=True
)
diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py
index 73b4a1c0a..697bf60ce 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -6,7 +6,7 @@ from email.parser import HeaderParser
from io import StringIO
from typing import Tuple, Union
-from discord import Colour, Embed
+from discord import Colour, Embed, utils
from discord.ext.commands import BadArgument, Cog, Context, command
from bot.bot import Bot
@@ -145,7 +145,7 @@ class Utils(Cog):
u_code = f"\\U{digit:>08}"
url = f"https://www.compart.com/en/unicode/U+{digit:>04}"
name = f"[{unicodedata.name(char, '')}]({url})"
- info = f"`{u_code.ljust(10)}`: {name} - {char}"
+ info = f"`{u_code.ljust(10)}`: {name} - {utils.escape_markdown(char)}"
return info, u_code
charlist, rawlist = zip(*(get_info(c) for c in characters))
diff --git a/bot/constants.py b/bot/constants.py
index b31a9c99e..470221369 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -389,6 +389,7 @@ class Channels(metaclass=YAMLGetter):
attachment_log: int
big_brother_logs: int
bot_commands: int
+ cooldown: int
defcon: int
dev_contrib: int
dev_core: int
diff --git a/bot/converters.py b/bot/converters.py
index 72c46fdf0..4deb59f87 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -217,7 +217,10 @@ class Duration(Converter):
delta = relativedelta(**duration_dict)
now = datetime.utcnow()
- return now + delta
+ try:
+ return now + delta
+ except ValueError:
+ raise BadArgument(f"`{duration}` results in a datetime outside the supported range.")
class ISODateTime(Converter):
diff --git a/bot/resources/tags/modmail.md b/bot/resources/tags/modmail.md
new file mode 100644
index 000000000..7545419ee
--- /dev/null
+++ b/bot/resources/tags/modmail.md
@@ -0,0 +1,9 @@
+**Contacting the moderation team via ModMail**
+
+<@!683001325440860340> is a bot that will relay your messages to our moderation team, so that you can start a conversation with the moderation team. Your messages will be relayed to the entire moderator team, who will be able to respond to you via the bot.
+
+It supports attachments, codeblocks, and reactions. As communication happens over direct messages, the conversation will stay between you and the mod team.
+
+**To use it, simply send a direct message to the bot.**
+
+Should there be an urgent and immediate need for a moderator or admin to look at a channel, feel free to ping the <@&267629731250176001> or <@&267628507062992896> role instead.
diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py
index de80cee84..354e987b9 100644
--- a/bot/utils/redis_cache.py
+++ b/bot/utils/redis_cache.py
@@ -100,16 +100,7 @@ class RedisCache:
def _set_namespace(self, namespace: str) -> None:
"""Try to set the namespace, but do not permit collisions."""
- # We need a unique namespace, to prevent collisions. This loop
- # will try appending underscores to the end of the namespace until
- # it finds one that is unique.
- #
- # For example, if `john` and `john_` are both taken, the namespace will
- # be `john__` at the end of this loop.
- while namespace in self._namespaces:
- namespace += "_"
-
- log.trace(f"RedisCache setting namespace to {self._namespace}")
+ log.trace(f"RedisCache setting namespace to {namespace}")
self._namespaces.append(namespace)
self._namespace = namespace
diff --git a/config-default.yml b/config-default.yml
index 2c85f5ef3..3388e5f78 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -142,6 +142,7 @@ guild:
# Python Help: Available
how_to_get_help: 704250143020417084
+ cooldown: 720603994149486673
# Logs
attachment_log: &ATTACH_LOG 649243850006855680
@@ -297,6 +298,7 @@ filter:
- 613425648685547541 # Discord Developers
- 185590609631903755 # Blender Hub
- 420324994703163402 # /r/FlutterDev
+ - 488751051629920277 # Python Atlanta
domain_blacklist:
- pornhub.com
diff --git a/docker-compose.yml b/docker-compose.yml
index 9884e35f0..cff7d33d6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -17,6 +17,14 @@ services:
ports:
- "127.0.0.1:6379:6379"
+ snekbox:
+ image: pythondiscord/snekbox:latest
+ init: true
+ ipc: none
+ ports:
+ - "127.0.0.1:8060:8060"
+ privileged: true
+
web:
image: pythondiscord/site:latest
command: ["run", "--debug"]
@@ -47,6 +55,7 @@ services:
depends_on:
- web
- redis
+ - snekbox
environment:
BOT_TOKEN: ${BOT_TOKEN}
BOT_API_KEY: badbot13m0n8f570f942013fc818f234916ca531
diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py
index 81398c61f..14fd909c4 100644
--- a/tests/bot/cogs/sync/test_cog.py
+++ b/tests/bot/cogs/sync/test_cog.py
@@ -247,14 +247,12 @@ class SyncCogListenerTests(SyncCogTestCase):
before_data = {
"name": "old name",
"discriminator": "1234",
- "avatar": "old avatar",
"bot": False,
}
subtests = (
(True, "name", "name", "new name", "new name"),
(True, "discriminator", "discriminator", "8765", 8765),
- (True, "avatar", "avatar_hash", "9j2e9", "9j2e9"),
(False, "bot", "bot", True, True),
)
@@ -295,7 +293,6 @@ class SyncCogListenerTests(SyncCogTestCase):
)
data = {
- "avatar_hash": member.avatar,
"discriminator": int(member.discriminator),
"id": member.id,
"in_guild": True,
diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py
index 818883012..002a947ad 100644
--- a/tests/bot/cogs/sync/test_users.py
+++ b/tests/bot/cogs/sync/test_users.py
@@ -10,7 +10,6 @@ def fake_user(**kwargs):
kwargs.setdefault("id", 43)
kwargs.setdefault("name", "bob the test man")
kwargs.setdefault("discriminator", 1337)
- kwargs.setdefault("avatar_hash", None)
kwargs.setdefault("roles", (666,))
kwargs.setdefault("in_guild", True)
@@ -32,7 +31,6 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
for member in members:
member = member.copy()
- member["avatar"] = member.pop("avatar_hash")
del member["in_guild"]
mock_member = helpers.MockMember(**member)
diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py
index aca6b594f..79c0e0ad3 100644
--- a/tests/bot/cogs/test_information.py
+++ b/tests/bot/cogs/test_information.py
@@ -148,14 +148,18 @@ class InformationCogTests(unittest.TestCase):
Voice region: {self.ctx.guild.region}
Features: {', '.join(self.ctx.guild.features)}
- **Counts**
- Members: {self.ctx.guild.member_count:,}
- Roles: {len(self.ctx.guild.roles)}
+ **Channel counts**
Category channels: 1
Text channels: 1
Voice channels: 1
+ Staff channels: 0
+
+ **Member counts**
+ Members: {self.ctx.guild.member_count:,}
+ Staff members: 0
+ Roles: {len(self.ctx.guild.roles)}
- **Members**
+ **Member statuses**
{constants.Emojis.status_online} 2
{constants.Emojis.status_idle} 1
{constants.Emojis.status_dnd} 4
diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py
index ca8cb6825..c42111f3f 100644
--- a/tests/bot/test_converters.py
+++ b/tests/bot/test_converters.py
@@ -1,5 +1,5 @@
-import asyncio
import datetime
+import re
import unittest
from unittest.mock import MagicMock, patch
@@ -16,7 +16,7 @@ from bot.converters import (
)
-class ConverterTests(unittest.TestCase):
+class ConverterTests(unittest.IsolatedAsyncioTestCase):
"""Tests our custom argument converters."""
@classmethod
@@ -26,7 +26,7 @@ class ConverterTests(unittest.TestCase):
cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00')
- def test_tag_content_converter_for_valid(self):
+ async def test_tag_content_converter_for_valid(self):
"""TagContentConverter should return correct values for valid input."""
test_values = (
('hello', 'hello'),
@@ -35,10 +35,10 @@ class ConverterTests(unittest.TestCase):
for content, expected_conversion in test_values:
with self.subTest(content=content, expected_conversion=expected_conversion):
- conversion = asyncio.run(TagContentConverter.convert(self.context, content))
+ conversion = await TagContentConverter.convert(self.context, content)
self.assertEqual(conversion, expected_conversion)
- def test_tag_content_converter_for_invalid(self):
+ async def test_tag_content_converter_for_invalid(self):
"""TagContentConverter should raise the proper exception for invalid input."""
test_values = (
('', "Tag contents should not be empty, or filled with whitespace."),
@@ -47,10 +47,10 @@ class ConverterTests(unittest.TestCase):
for value, exception_message in test_values:
with self.subTest(tag_content=value, exception_message=exception_message):
- with self.assertRaises(BadArgument, msg=exception_message):
- asyncio.run(TagContentConverter.convert(self.context, value))
+ with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
+ await TagContentConverter.convert(self.context, value)
- def test_tag_name_converter_for_valid(self):
+ async def test_tag_name_converter_for_valid(self):
"""TagNameConverter should return the correct values for valid tag names."""
test_values = (
('tracebacks', 'tracebacks'),
@@ -60,10 +60,10 @@ class ConverterTests(unittest.TestCase):
for name, expected_conversion in test_values:
with self.subTest(name=name, expected_conversion=expected_conversion):
- conversion = asyncio.run(TagNameConverter.convert(self.context, name))
+ conversion = await TagNameConverter.convert(self.context, name)
self.assertEqual(conversion, expected_conversion)
- def test_tag_name_converter_for_invalid(self):
+ async def test_tag_name_converter_for_invalid(self):
"""TagNameConverter should raise the correct exception for invalid tag names."""
test_values = (
('👋', "Don't be ridiculous, you can't use that character!"),
@@ -75,29 +75,29 @@ class ConverterTests(unittest.TestCase):
for invalid_name, exception_message in test_values:
with self.subTest(invalid_name=invalid_name, exception_message=exception_message):
- with self.assertRaises(BadArgument, msg=exception_message):
- asyncio.run(TagNameConverter.convert(self.context, invalid_name))
+ with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
+ await TagNameConverter.convert(self.context, invalid_name)
- def test_valid_python_identifier_for_valid(self):
+ async def test_valid_python_identifier_for_valid(self):
"""ValidPythonIdentifier returns valid identifiers unchanged."""
test_values = ('foo', 'lemon')
for name in test_values:
with self.subTest(identifier=name):
- conversion = asyncio.run(ValidPythonIdentifier.convert(self.context, name))
+ conversion = await ValidPythonIdentifier.convert(self.context, name)
self.assertEqual(name, conversion)
- def test_valid_python_identifier_for_invalid(self):
+ async def test_valid_python_identifier_for_invalid(self):
"""ValidPythonIdentifier raises the proper exception for invalid identifiers."""
test_values = ('nested.stuff', '#####')
for name in test_values:
with self.subTest(identifier=name):
exception_message = f'`{name}` is not a valid Python identifier'
- with self.assertRaises(BadArgument, msg=exception_message):
- asyncio.run(ValidPythonIdentifier.convert(self.context, name))
+ with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
+ await ValidPythonIdentifier.convert(self.context, name)
- def test_duration_converter_for_valid(self):
+ async def test_duration_converter_for_valid(self):
"""Duration returns the correct `datetime` for valid duration strings."""
test_values = (
# Simple duration strings
@@ -159,35 +159,35 @@ class ConverterTests(unittest.TestCase):
mock_datetime.utcnow.return_value = self.fixed_utc_now
with self.subTest(duration=duration, duration_dict=duration_dict):
- converted_datetime = asyncio.run(converter.convert(self.context, duration))
+ converted_datetime = await converter.convert(self.context, duration)
self.assertEqual(converted_datetime, expected_datetime)
- def test_duration_converter_for_invalid(self):
+ async def test_duration_converter_for_invalid(self):
"""Duration raises the right exception for invalid duration strings."""
test_values = (
# Units in wrong order
- ('1d1w'),
- ('1s1y'),
+ '1d1w',
+ '1s1y',
# Duplicated units
- ('1 year 2 years'),
- ('1 M 10 minutes'),
+ '1 year 2 years',
+ '1 M 10 minutes',
# Unknown substrings
- ('1MVes'),
- ('1y3breads'),
+ '1MVes',
+ '1y3breads',
# Missing amount
- ('ym'),
+ 'ym',
# Incorrect whitespace
- (" 1y"),
- ("1S "),
- ("1y 1m"),
+ " 1y",
+ "1S ",
+ "1y 1m",
# Garbage
- ('Guido van Rossum'),
- ('lemon lemon lemon lemon lemon lemon lemon'),
+ 'Guido van Rossum',
+ 'lemon lemon lemon lemon lemon lemon lemon',
)
converter = Duration()
@@ -195,10 +195,21 @@ class ConverterTests(unittest.TestCase):
for invalid_duration in test_values:
with self.subTest(invalid_duration=invalid_duration):
exception_message = f'`{invalid_duration}` is not a valid duration string.'
- with self.assertRaises(BadArgument, msg=exception_message):
- asyncio.run(converter.convert(self.context, invalid_duration))
+ with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
+ await converter.convert(self.context, invalid_duration)
- def test_isodatetime_converter_for_valid(self):
+ @patch("bot.converters.datetime")
+ async def test_duration_converter_out_of_range(self, mock_datetime):
+ """Duration converter should raise BadArgument if datetime raises a ValueError."""
+ mock_datetime.__add__.side_effect = ValueError
+ mock_datetime.utcnow.return_value = mock_datetime
+
+ duration = f"{datetime.MAXYEAR}y"
+ exception_message = f"`{duration}` results in a datetime outside the supported range."
+ with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
+ await Duration().convert(self.context, duration)
+
+ async def test_isodatetime_converter_for_valid(self):
"""ISODateTime converter returns correct datetime for valid datetime string."""
test_values = (
# `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ`
@@ -243,37 +254,37 @@ class ConverterTests(unittest.TestCase):
for datetime_string, expected_dt in test_values:
with self.subTest(datetime_string=datetime_string, expected_dt=expected_dt):
- converted_dt = asyncio.run(converter.convert(self.context, datetime_string))
+ converted_dt = await converter.convert(self.context, datetime_string)
self.assertIsNone(converted_dt.tzinfo)
self.assertEqual(converted_dt, expected_dt)
- def test_isodatetime_converter_for_invalid(self):
+ async def test_isodatetime_converter_for_invalid(self):
"""ISODateTime converter raises the correct exception for invalid datetime strings."""
test_values = (
# Make sure it doesn't interfere with the Duration converter
- ('1Y'),
- ('1d'),
- ('1H'),
+ '1Y',
+ '1d',
+ '1H',
# Check if it fails when only providing the optional time part
- ('10:10:10'),
- ('10:00'),
+ '10:10:10',
+ '10:00',
# Invalid date format
- ('19-01-01'),
+ '19-01-01',
# Other non-valid strings
- ('fisk the tag master'),
+ 'fisk the tag master',
)
converter = ISODateTime()
for datetime_string in test_values:
with self.subTest(datetime_string=datetime_string):
exception_message = f"`{datetime_string}` is not a valid ISO-8601 datetime string"
- with self.assertRaises(BadArgument, msg=exception_message):
- asyncio.run(converter.convert(self.context, datetime_string))
+ with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
+ await converter.convert(self.context, datetime_string)
- def test_hush_duration_converter_for_valid(self):
+ async def test_hush_duration_converter_for_valid(self):
"""HushDurationConverter returns correct value for minutes duration or `"forever"` strings."""
test_values = (
("0", 0),
@@ -286,10 +297,10 @@ class ConverterTests(unittest.TestCase):
converter = HushDurationConverter()
for minutes_string, expected_minutes in test_values:
with self.subTest(minutes_string=minutes_string, expected_minutes=expected_minutes):
- converted = asyncio.run(converter.convert(self.context, minutes_string))
+ converted = await converter.convert(self.context, minutes_string)
self.assertEqual(expected_minutes, converted)
- def test_hush_duration_converter_for_invalid(self):
+ async def test_hush_duration_converter_for_invalid(self):
"""HushDurationConverter raises correct exception for invalid minutes duration strings."""
test_values = (
("16", "Duration must be at most 15 minutes."),
@@ -299,5 +310,5 @@ class ConverterTests(unittest.TestCase):
converter = HushDurationConverter()
for invalid_minutes_string, exception_message in test_values:
with self.subTest(invalid_minutes_string=invalid_minutes_string, exception_message=exception_message):
- with self.assertRaisesRegex(BadArgument, exception_message):
- asyncio.run(converter.convert(self.context, invalid_minutes_string))
+ with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
+ await converter.convert(self.context, invalid_minutes_string)
diff --git a/tests/bot/utils/test_redis_cache.py b/tests/bot/utils/test_redis_cache.py
index 8c1a40640..e5d6e4078 100644
--- a/tests/bot/utils/test_redis_cache.py
+++ b/tests/bot/utils/test_redis_cache.py
@@ -44,16 +44,6 @@ class RedisCacheTests(unittest.IsolatedAsyncioTestCase):
with self.assertRaises(RuntimeError):
await bad_cache.set("test", "me_up_deadman")
- def test_namespace_collision(self):
- """Test that we prevent colliding namespaces."""
- bob_cache_1 = RedisCache()
- bob_cache_1._set_namespace("BobRoss")
- self.assertEqual(bob_cache_1._namespace, "BobRoss")
-
- bob_cache_2 = RedisCache()
- bob_cache_2._set_namespace("BobRoss")
- self.assertEqual(bob_cache_2._namespace, "BobRoss_")
-
async def test_set_get_item(self):
"""Test that users can set and get items from the RedisDict."""
test_cases = (