aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Dockerfile2
-rw-r--r--bot/errors.py14
-rw-r--r--bot/exts/events/__init__.py0
-rw-r--r--bot/exts/events/code_jams/__init__.py8
-rw-r--r--bot/exts/events/code_jams/_channels.py113
-rw-r--r--bot/exts/events/code_jams/_cog.py235
-rw-r--r--bot/exts/filters/antimalware.py2
-rw-r--r--bot/exts/filters/antispam.py2
-rw-r--r--bot/exts/filters/filtering.py2
-rw-r--r--bot/exts/fun/duck_pond.py8
-rw-r--r--bot/exts/help_channels/_cog.py10
-rw-r--r--bot/exts/help_channels/_message.py8
-rw-r--r--bot/exts/info/codeblock/_cog.py17
-rw-r--r--bot/exts/info/information.py35
-rw-r--r--bot/exts/moderation/defcon.py8
-rw-r--r--bot/exts/moderation/incidents.py24
-rw-r--r--bot/exts/moderation/infraction/management.py2
-rw-r--r--bot/exts/moderation/metabase.py4
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py70
-rw-r--r--bot/exts/recruitment/talentpool/_review.py22
-rw-r--r--bot/exts/utils/jams.py176
-rw-r--r--bot/exts/utils/reminders.py2
-rw-r--r--bot/pagination.py2
-rw-r--r--bot/resources/tags/blocking.md5
-rw-r--r--bot/resources/tags/modmail.md2
-rw-r--r--bot/utils/messages.py27
-rw-r--r--tests/bot/exts/events/__init__.py0
-rw-r--r--tests/bot/exts/events/test_code_jams.py (renamed from tests/bot/exts/utils/test_jams.py)66
28 files changed, 584 insertions, 282 deletions
diff --git a/Dockerfile b/Dockerfile
index c285898dc..4d8592590 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.9.5-slim
+FROM python:3.9-slim
# Set pip to have no saved cache
ENV PIP_NO_CACHE_DIR=false \
diff --git a/bot/errors.py b/bot/errors.py
index 46efb6d4f..5785faa44 100644
--- a/bot/errors.py
+++ b/bot/errors.py
@@ -41,3 +41,17 @@ class BrandingMisconfiguration(RuntimeError):
"""Raised by the Branding cog when a misconfigured event is encountered."""
pass
+
+
+class NonExistentRoleError(ValueError):
+ """
+ Raised by the Information Cog when encountering a Role that does not exist.
+
+ Attributes:
+ `role_id` -- the ID of the role that does not exist
+ """
+
+ def __init__(self, role_id: int):
+ super().__init__(f"Could not fetch data for role {role_id}")
+
+ self.role_id = role_id
diff --git a/bot/exts/events/__init__.py b/bot/exts/events/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bot/exts/events/__init__.py
diff --git a/bot/exts/events/code_jams/__init__.py b/bot/exts/events/code_jams/__init__.py
new file mode 100644
index 000000000..16e81e365
--- /dev/null
+++ b/bot/exts/events/code_jams/__init__.py
@@ -0,0 +1,8 @@
+from bot.bot import Bot
+
+
+def setup(bot: Bot) -> None:
+ """Load the CodeJams cog."""
+ from bot.exts.events.code_jams._cog import CodeJams
+
+ bot.add_cog(CodeJams(bot))
diff --git a/bot/exts/events/code_jams/_channels.py b/bot/exts/events/code_jams/_channels.py
new file mode 100644
index 000000000..34ff0ad41
--- /dev/null
+++ b/bot/exts/events/code_jams/_channels.py
@@ -0,0 +1,113 @@
+import logging
+import typing as t
+
+import discord
+
+from bot.constants import Categories, Channels, Roles
+
+log = logging.getLogger(__name__)
+
+MAX_CHANNELS = 50
+CATEGORY_NAME = "Code Jam"
+
+
+async def _get_category(guild: discord.Guild) -> discord.CategoryChannel:
+ """
+ Return a code jam category.
+
+ If all categories are full or none exist, create a new category.
+ """
+ for category in guild.categories:
+ if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS:
+ return category
+
+ return await _create_category(guild)
+
+
+async def _create_category(guild: discord.Guild) -> discord.CategoryChannel:
+ """Create a new code jam category and return it."""
+ log.info("Creating a new code jam category.")
+
+ category_overwrites = {
+ guild.default_role: discord.PermissionOverwrite(read_messages=False),
+ guild.me: discord.PermissionOverwrite(read_messages=True)
+ }
+
+ category = await guild.create_category_channel(
+ CATEGORY_NAME,
+ overwrites=category_overwrites,
+ reason="It's code jam time!"
+ )
+
+ await _send_status_update(
+ guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels."
+ )
+
+ return category
+
+
+def _get_overwrites(
+ members: list[tuple[discord.Member, bool]],
+ guild: discord.Guild,
+) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]:
+ """Get code jam team channels permission overwrites."""
+ team_channel_overwrites = {
+ guild.default_role: discord.PermissionOverwrite(read_messages=False),
+ guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True)
+ }
+
+ for member, _ in members:
+ team_channel_overwrites[member] = discord.PermissionOverwrite(
+ read_messages=True
+ )
+
+ return team_channel_overwrites
+
+
+async def create_team_channel(
+ guild: discord.Guild,
+ team_name: str,
+ members: list[tuple[discord.Member, bool]],
+ team_leaders: discord.Role
+) -> None:
+ """Create the team's text channel."""
+ await _add_team_leader_roles(members, team_leaders)
+
+ # Get permission overwrites and category
+ team_channel_overwrites = _get_overwrites(members, guild)
+ code_jam_category = await _get_category(guild)
+
+ # Create a text channel for the team
+ await code_jam_category.create_text_channel(
+ team_name,
+ overwrites=team_channel_overwrites,
+ )
+
+
+async def create_team_leader_channel(guild: discord.Guild, team_leaders: discord.Role) -> None:
+ """Create the Team Leader Chat channel for the Code Jam team leaders."""
+ category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam)
+
+ team_leaders_chat = await category.create_text_channel(
+ name="team-leaders-chat",
+ overwrites={
+ guild.default_role: discord.PermissionOverwrite(read_messages=False),
+ team_leaders: discord.PermissionOverwrite(read_messages=True)
+ }
+ )
+
+ await _send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.")
+
+
+async def _send_status_update(guild: discord.Guild, message: str) -> None:
+ """Inform the events lead with a status update when the command is ran."""
+ channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning)
+
+ await channel.send(f"<@&{Roles.events_lead}>\n\n{message}")
+
+
+async def _add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None:
+ """Assign the team leader role to the team leaders."""
+ for member, is_leader in members:
+ if is_leader:
+ await member.add_roles(team_leaders)
diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py
new file mode 100644
index 000000000..e099f7dfa
--- /dev/null
+++ b/bot/exts/events/code_jams/_cog.py
@@ -0,0 +1,235 @@
+import asyncio
+import csv
+import logging
+import typing as t
+from collections import defaultdict
+
+import discord
+from discord import Colour, Embed, Guild, Member
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Emojis, Roles
+from bot.exts.events.code_jams import _channels
+from bot.utils.services import send_to_paste_service
+
+log = logging.getLogger(__name__)
+
+TEAM_LEADERS_COLOUR = 0x11806a
+DELETION_REACTION = "\U0001f4a5"
+
+
+class CodeJams(commands.Cog):
+ """Manages the code-jam related parts of our server."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @commands.group(aliases=("cj", "jam"))
+ @commands.has_any_role(Roles.admins)
+ async def codejam(self, ctx: commands.Context) -> None:
+ """A Group of commands for managing Code Jams."""
+ if ctx.invoked_subcommand is None:
+ await ctx.send_help(ctx.command)
+
+ @codejam.command()
+ async def create(self, ctx: commands.Context, csv_file: t.Optional[str] = None) -> None:
+ """
+ Create code-jam teams from a CSV file or a link to one, specifying the team names, leaders and members.
+
+ The CSV file must have 3 columns: 'Team Name', 'Team Member Discord ID', and 'Team Leader'.
+
+ This will create the text channels for the teams, and give the team leaders their roles.
+ """
+ async with ctx.typing():
+ if csv_file:
+ async with self.bot.http_session.get(csv_file) as response:
+ if response.status != 200:
+ await ctx.send(f"Got a bad response from the URL: {response.status}")
+ return
+
+ csv_file = await response.text()
+
+ elif ctx.message.attachments:
+ csv_file = (await ctx.message.attachments[0].read()).decode("utf8")
+ else:
+ raise commands.BadArgument("You must include either a CSV file or a link to one.")
+
+ teams = defaultdict(list)
+ reader = csv.DictReader(csv_file.splitlines())
+
+ for row in reader:
+ member = ctx.guild.get_member(int(row["Team Member Discord ID"]))
+
+ if member is None:
+ log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}")
+ continue
+
+ teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y"))
+
+ team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR)
+
+ for team_name, members in teams.items():
+ await _channels.create_team_channel(ctx.guild, team_name, members, team_leaders)
+
+ await _channels.create_team_leader_channel(ctx.guild, team_leaders)
+ await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.")
+
+ @codejam.command()
+ @commands.has_any_role(Roles.admins)
+ async def end(self, ctx: commands.Context) -> None:
+ """
+ Delete all code jam channels.
+
+ A confirmation message is displayed with the categories and channels to be deleted.. Pressing the added reaction
+ deletes those channels.
+ """
+ def predicate_deletion_emoji_reaction(reaction: discord.Reaction, user: discord.User) -> bool:
+ """Return True if the reaction :boom: was added by the context message author on this message."""
+ return (
+ reaction.message.id == message.id
+ and user.id == ctx.author.id
+ and str(reaction) == DELETION_REACTION
+ )
+
+ # A copy of the list of channels is stored. This is to make sure that we delete precisely the channels displayed
+ # in the confirmation message.
+ categories = self.jam_categories(ctx.guild)
+ category_channels = {category: category.channels.copy() for category in categories}
+
+ confirmation_message = await self._build_confirmation_message(category_channels)
+ message = await ctx.send(confirmation_message)
+ await message.add_reaction(DELETION_REACTION)
+ try:
+ await self.bot.wait_for(
+ 'reaction_add',
+ check=predicate_deletion_emoji_reaction,
+ timeout=10
+ )
+
+ except asyncio.TimeoutError:
+ await message.clear_reaction(DELETION_REACTION)
+ await ctx.send("Command timed out.", reference=message)
+ return
+
+ else:
+ await message.clear_reaction(DELETION_REACTION)
+ for category, channels in category_channels.items():
+ for channel in channels:
+ await channel.delete(reason="Code jam ended.")
+ await category.delete(reason="Code jam ended.")
+
+ await message.add_reaction(Emojis.check_mark)
+
+ @staticmethod
+ async def _build_confirmation_message(
+ categories: dict[discord.CategoryChannel, list[discord.abc.GuildChannel]]
+ ) -> str:
+ """Sends details of the channels to be deleted to the pasting service, and formats the confirmation message."""
+ def channel_repr(channel: discord.abc.GuildChannel) -> str:
+ """Formats the channel name and ID and a readable format."""
+ return f"{channel.name} ({channel.id})"
+
+ def format_category_info(category: discord.CategoryChannel, channels: list[discord.abc.GuildChannel]) -> str:
+ """Displays the category and the channels within it in a readable format."""
+ return f"{channel_repr(category)}:\n" + "\n".join(" - " + channel_repr(channel) for channel in channels)
+
+ deletion_details = "\n\n".join(
+ format_category_info(category, channels) for category, channels in categories.items()
+ )
+
+ url = await send_to_paste_service(deletion_details)
+ if url is None:
+ url = "**Unable to send deletion details to the pasting service.**"
+
+ return f"Are you sure you want to delete all code jam channels?\n\nThe channels to be deleted: {url}"
+
+ @codejam.command()
+ @commands.has_any_role(Roles.admins, Roles.code_jam_event_team)
+ async def info(self, ctx: commands.Context, member: Member) -> None:
+ """
+ Send an info embed about the member with the team they're in.
+
+ The team is found by searching the permissions of the team channels.
+ """
+ channel = self.team_channel(ctx.guild, member)
+ if not channel:
+ await ctx.send(":x: I can't find the team channel for this member.")
+ return
+
+ embed = Embed(
+ title=str(member),
+ colour=Colour.blurple()
+ )
+ embed.add_field(name="Team", value=self.team_name(channel), inline=True)
+
+ await ctx.send(embed=embed)
+
+ @codejam.command()
+ @commands.has_any_role(Roles.admins)
+ async def move(self, ctx: commands.Context, member: Member, new_team_name: str) -> None:
+ """Move participant from one team to another by changing the user's permissions for the relevant channels."""
+ old_team_channel = self.team_channel(ctx.guild, member)
+ if not old_team_channel:
+ await ctx.send(":x: I can't find the team channel for this member.")
+ return
+
+ if old_team_channel.name == new_team_name or self.team_name(old_team_channel) == new_team_name:
+ await ctx.send(f"`{member}` is already in `{new_team_name}`.")
+ return
+
+ new_team_channel = self.team_channel(ctx.guild, new_team_name)
+ if not new_team_channel:
+ await ctx.send(f":x: I can't find a team channel named `{new_team_name}`.")
+ return
+
+ await old_team_channel.set_permissions(member, overwrite=None, reason=f"Participant moved to {new_team_name}")
+ await new_team_channel.set_permissions(
+ member,
+ overwrite=discord.PermissionOverwrite(read_messages=True),
+ reason=f"Participant moved from {old_team_channel.name}"
+ )
+
+ await ctx.send(
+ f"Participant moved from `{self.team_name(old_team_channel)}` to `{self.team_name(new_team_channel)}`."
+ )
+
+ @codejam.command()
+ @commands.has_any_role(Roles.admins)
+ async def remove(self, ctx: commands.Context, member: Member) -> None:
+ """Remove the participant from their team. Does not remove the participants or leader roles."""
+ channel = self.team_channel(ctx.guild, member)
+ if not channel:
+ await ctx.send(":x: I can't find the team channel for this member.")
+ return
+
+ await channel.set_permissions(
+ member,
+ overwrite=None,
+ reason=f"Participant removed from the team {self.team_name(channel)}."
+ )
+ await ctx.send(f"Removed the participant from `{self.team_name(channel)}`.")
+
+ @staticmethod
+ def jam_categories(guild: Guild) -> list[discord.CategoryChannel]:
+ """Get all the code jam team categories."""
+ return [category for category in guild.categories if category.name == _channels.CATEGORY_NAME]
+
+ @staticmethod
+ def team_channel(guild: Guild, criterion: t.Union[str, Member]) -> t.Optional[discord.TextChannel]:
+ """Get a team channel through either a participant or the team name."""
+ for category in CodeJams.jam_categories(guild):
+ for channel in category.channels:
+ if isinstance(channel, discord.TextChannel):
+ if (
+ # If it's a string.
+ criterion == channel.name or criterion == CodeJams.team_name(channel)
+ # If it's a member.
+ or criterion in channel.overwrites
+ ):
+ return channel
+
+ @staticmethod
+ def team_name(channel: discord.TextChannel) -> str:
+ """Retrieves the team name from the given channel."""
+ return channel.name.replace("-", " ").title()
diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py
index 4c4836c88..0eedeb0fb 100644
--- a/bot/exts/filters/antimalware.py
+++ b/bot/exts/filters/antimalware.py
@@ -7,7 +7,7 @@ from discord.ext.commands import Cog
from bot.bot import Bot
from bot.constants import Channels, Filter, URLs
-from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME
+from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME
log = logging.getLogger(__name__)
diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py
index 3f891b2c6..226da2790 100644
--- a/bot/exts/filters/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -17,8 +17,8 @@ from bot.constants import (
Guild as GuildConfig, Icons,
)
from bot.converters import Duration
+from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME
from bot.exts.moderation.modlog import ModLog
-from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME
from bot.utils import lock, scheduling
from bot.utils.messages import format_user, send_attachments
diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py
index 16aaf11cf..10cc7885d 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -19,8 +19,8 @@ from bot.constants import (
Channels, Colours, Filter,
Guild, Icons, URLs
)
+from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME
from bot.exts.moderation.modlog import ModLog
-from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME
from bot.utils.messages import format_user
from bot.utils.regex import INVITE_RE
from bot.utils.scheduling import Scheduler
diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py
index c78b9c141..d02912545 100644
--- a/bot/exts/fun/duck_pond.py
+++ b/bot/exts/fun/duck_pond.py
@@ -171,8 +171,14 @@ class DuckPond(Cog):
if not self.is_helper_viewable(channel):
return
- message = await channel.fetch_message(payload.message_id)
+ try:
+ message = await channel.fetch_message(payload.message_id)
+ except discord.NotFound:
+ return # Message was deleted.
+
member = discord.utils.get(message.guild.members, id=payload.user_id)
+ if not member:
+ return # Member left or wasn't in the cache.
# Was the message sent by a human staff member?
if not self.is_staff(message.author) or message.author.bot:
diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py
index 35658d117..afaf9b0bd 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -387,7 +387,15 @@ class HelpChannels(commands.Cog):
)
log.trace(f"Sending dormant message for #{channel} ({channel.id}).")
- embed = discord.Embed(description=_message.DORMANT_MSG)
+ dormant_category = await channel_utils.try_get_channel(constants.Categories.help_dormant)
+ available_category = await channel_utils.try_get_channel(constants.Categories.help_available)
+ embed = discord.Embed(
+ description=_message.DORMANT_MSG.format(
+ dormant=dormant_category.name,
+ available=available_category.name,
+ asking_guide=_message.ASKING_GUIDE_URL
+ )
+ )
await channel.send(embed=embed)
log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.")
diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py
index befacd263..cf070be83 100644
--- a/bot/exts/help_channels/_message.py
+++ b/bot/exts/help_channels/_message.py
@@ -29,15 +29,15 @@ AVAILABLE_TITLE = "Available help channel"
AVAILABLE_FOOTER = "Closes after a period of inactivity, or when you send !close."
-DORMANT_MSG = f"""
-This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \
+DORMANT_MSG = """
+This help channel has been marked as **dormant**, and has been moved into the **{dormant}** \
category at the bottom of the channel list. It is no longer possible to send messages in this \
channel until it becomes available again.
If your question wasn't answered yet, you can claim a new help channel from the \
-**Help: Available** category by simply asking your question again. Consider rephrasing the \
+**{available}** category by simply asking your question again. Consider rephrasing the \
question to maximize your chance of getting a good answer. If you're not sure how, have a look \
-through our guide for **[asking a good question]({ASKING_GUIDE_URL})**.
+through our guide for **[asking a good question]({asking_guide})**.
"""
diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py
index 9094d9d15..9a0705d2b 100644
--- a/bot/exts/info/codeblock/_cog.py
+++ b/bot/exts/info/codeblock/_cog.py
@@ -177,10 +177,13 @@ class CodeBlockCog(Cog, name="Code Block"):
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))
+ try:
+ if not instructions:
+ log.info("User's incorrect code block was 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 instructions.")
+ await bot_message.edit(embed=self.create_embed(instructions))
+ except discord.NotFound:
+ log.debug("Could not find instructions message; it was probably deleted.")
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index bb713eef1..167731e64 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -3,7 +3,7 @@ import logging
import pprint
import textwrap
from collections import defaultdict
-from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union
+from typing import Any, DefaultDict, Mapping, Optional, Tuple, Union
import rapidfuzz
from discord import AllowedMentions, Colour, Embed, Guild, Message, Role
@@ -14,6 +14,7 @@ from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.converters import FetchedMember
from bot.decorators import in_whitelist
+from bot.errors import NonExistentRoleError
from bot.pagination import LinePaginator
from bot.utils.channel import is_mod_channel, is_staff_channel
from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check
@@ -42,15 +43,29 @@ class Information(Cog):
return channel_counter
@staticmethod
- def get_member_counts(guild: Guild) -> Dict[str, int]:
+ def join_role_stats(role_ids: list[int], guild: Guild, name: Optional[str] = None) -> dict[str, int]:
+ """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group."""
+ members = 0
+ for role_id in role_ids:
+ if (role := guild.get_role(role_id)) is not None:
+ members += len(role.members)
+ else:
+ raise NonExistentRoleError(role_id)
+ return {name or role.name.title(): members}
+
+ @staticmethod
+ def get_member_counts(guild: Guild) -> dict[str, int]:
"""Return the total number of members for certain roles in `guild`."""
- roles = (
- guild.get_role(role_id) for role_id in (
- constants.Roles.helpers, constants.Roles.mod_team, constants.Roles.admins,
- constants.Roles.owners, constants.Roles.contributors,
- )
+ role_ids = [constants.Roles.helpers, constants.Roles.mod_team, constants.Roles.admins,
+ constants.Roles.owners, constants.Roles.contributors]
+
+ role_stats = {}
+ for role_id in role_ids:
+ role_stats.update(Information.join_role_stats([role_id], guild))
+ role_stats.update(
+ Information.join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], guild, "Leads")
)
- return {role.name.title(): len(role.members) for role in roles}
+ return role_stats
def get_extended_server_info(self, ctx: Context) -> str:
"""Return additional server info only visible in moderation channels."""
@@ -243,7 +258,9 @@ class Information(Cog):
if on_server:
joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE)
- roles = ", ".join(role.mention for role in user.roles[1:])
+ # The 0 is for excluding the default @everyone role,
+ # and the -1 is for reversing the order of the roles to highest to lowest in hierarchy.
+ roles = ", ".join(role.mention for role in user.roles[:0:-1])
membership = {"Joined": joined, "Verified": not user.pending, "Roles": roles or None}
if not is_mod_channel(ctx.channel):
membership.pop("Verified")
diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py
index 9801d45ad..6ac077b93 100644
--- a/bot/exts/moderation/defcon.py
+++ b/bot/exts/moderation/defcon.py
@@ -9,7 +9,7 @@ from typing import Optional, Union
from aioredis import RedisError
from async_rediscache import RedisCache
from dateutil.relativedelta import relativedelta
-from discord import Colour, Embed, Member, User
+from discord import Colour, Embed, Forbidden, Member, User
from discord.ext import tasks
from discord.ext.commands import Cog, Context, group, has_any_role
@@ -118,10 +118,12 @@ class Defcon(Cog):
try:
await member.send(REJECTION_MESSAGE.format(user=member.mention))
-
message_sent = True
+ except Forbidden:
+ log.debug(f"Cannot send DEFCON rejection DM to {member}: DMs disabled")
except Exception:
- log.exception(f"Unable to send rejection message to user: {member}")
+ # Broadly catch exceptions because DM isn't critical, but it's imperative to kick them.
+ log.exception(f"Error sending DEFCON rejection message to {member}")
await member.kick(reason="DEFCON active, user is too new")
self.bot.stats.incr("defcon.leaves")
diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py
index 0e479d33f..561e0251e 100644
--- a/bot/exts/moderation/incidents.py
+++ b/bot/exts/moderation/incidents.py
@@ -143,7 +143,14 @@ async def add_signals(incident: discord.Message) -> None:
log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}")
else:
log.trace(f"Adding reaction: {signal_emoji}")
- await incident.add_reaction(signal_emoji.value)
+ try:
+ await incident.add_reaction(signal_emoji.value)
+ except discord.NotFound as e:
+ if e.code != 10008:
+ raise
+
+ log.trace(f"Couldn't react with signal because message {incident.id} was deleted; skipping incident")
+ return
class Incidents(Cog):
@@ -288,14 +295,20 @@ class Incidents(Cog):
members_roles: t.Set[int] = {role.id for role in member.roles}
if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element
log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals")
- await incident.remove_reaction(reaction, member)
+ try:
+ await incident.remove_reaction(reaction, member)
+ except discord.NotFound:
+ log.trace("Couldn't remove reaction because the reaction or its message was deleted")
return
try:
signal = Signal(reaction)
except ValueError:
log.debug(f"Removing invalid reaction: emoji {reaction} is not a valid signal")
- await incident.remove_reaction(reaction, member)
+ try:
+ await incident.remove_reaction(reaction, member)
+ except discord.NotFound:
+ log.trace("Couldn't remove reaction because the reaction or its message was deleted")
return
log.trace(f"Received signal: {signal}")
@@ -313,7 +326,10 @@ class Incidents(Cog):
confirmation_task = self.make_confirmation_task(incident, timeout)
log.trace("Deleting original message")
- await incident.delete()
+ try:
+ await incident.delete()
+ except discord.NotFound:
+ log.trace("Couldn't delete message because it was already deleted")
log.trace(f"Awaiting deletion confirmation: {timeout=} seconds")
try:
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index 4b0cb78a5..3094159cd 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -81,7 +81,7 @@ class ModManagement(commands.Cog):
"""
old_reason = infraction["reason"]
- if old_reason is not None:
+ if old_reason is not None and reason is not None:
add_period = not old_reason.endswith((".", "!", "?"))
reason = old_reason + (". " if add_period else " ") + reason
diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py
index db5f04d83..e9faf7240 100644
--- a/bot/exts/moderation/metabase.py
+++ b/bot/exts/moderation/metabase.py
@@ -115,12 +115,12 @@ class Metabase(Cog):
try:
async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp:
if extension == "csv":
- out = await resp.text()
+ out = await resp.text(encoding="utf-8")
# Save the output for use with int e
self.exports[question_id] = list(csv.DictReader(StringIO(out)))
elif extension == "json":
- out = await resp.json()
+ out = await resp.json(encoding="utf-8")
# Save the output for use with int e
self.exports[question_id] = out
diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py
index 03326cab2..80bd48534 100644
--- a/bot/exts/recruitment/talentpool/_cog.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -5,18 +5,20 @@ from io import StringIO
from typing import Union
import discord
+from async_rediscache import RedisCache
from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User
from discord.ext.commands import Cog, Context, group, has_any_role
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks
+from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, Webhooks
from bot.converters import FetchedMember
from bot.exts.moderation.watchchannels._watchchannel import WatchChannel
from bot.exts.recruitment.talentpool._review import Reviewer
from bot.pagination import LinePaginator
from bot.utils import time
+AUTOREVIEW_ENABLED_KEY = "autoreview_enabled"
REASON_MAX_CHARS = 1000
log = logging.getLogger(__name__)
@@ -25,6 +27,10 @@ log = logging.getLogger(__name__)
class TalentPool(WatchChannel, Cog, name="Talentpool"):
"""Relays messages of helper candidates to a watch channel to observe them."""
+ # RedisCache[str, bool]
+ # Can contain a single key, "autoreview_enabled", with the value a bool indicating if autoreview is enabled.
+ talentpool_settings = RedisCache()
+
def __init__(self, bot: Bot) -> None:
super().__init__(
bot,
@@ -37,7 +43,18 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
)
self.reviewer = Reviewer(self.__class__.__name__, bot, self)
- self.bot.loop.create_task(self.reviewer.reschedule_reviews())
+ self.bot.loop.create_task(self.schedule_autoreviews())
+
+ async def schedule_autoreviews(self) -> None:
+ """Reschedule reviews for active nominations if autoreview is enabled."""
+ if await self.autoreview_enabled():
+ await self.reviewer.reschedule_reviews()
+ else:
+ self.log.trace("Not scheduling reviews as autoreview is disabled.")
+
+ async def autoreview_enabled(self) -> bool:
+ """Return whether automatic posting of nomination reviews is enabled."""
+ return await self.talentpool_settings.get(AUTOREVIEW_ENABLED_KEY, True)
@group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True)
@has_any_role(*MODERATION_ROLES)
@@ -45,6 +62,50 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
"""Highlights the activity of helper nominees by relaying their messages to the talent pool channel."""
await ctx.send_help(ctx.command)
+ @nomination_group.group(name="autoreview", aliases=("ar",), invoke_without_command=True)
+ @has_any_role(*MODERATION_ROLES)
+ async def nomination_autoreview_group(self, ctx: Context) -> None:
+ """Commands for enabling or disabling autoreview."""
+ await ctx.send_help(ctx.command)
+
+ @nomination_autoreview_group.command(name="enable", aliases=("on",))
+ @has_any_role(Roles.admins)
+ async def autoreview_enable(self, ctx: Context) -> None:
+ """
+ Enable automatic posting of reviews.
+
+ This will post reviews up to one day overdue. Older nominations can be
+ manually reviewed with the `tp post_review <user_id>` command.
+ """
+ if await self.autoreview_enabled():
+ await ctx.send(":x: Autoreview is already enabled")
+ return
+
+ await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, True)
+ await self.reviewer.reschedule_reviews()
+ await ctx.send(":white_check_mark: Autoreview enabled")
+
+ @nomination_autoreview_group.command(name="disable", aliases=("off",))
+ @has_any_role(Roles.admins)
+ async def autoreview_disable(self, ctx: Context) -> None:
+ """Disable automatic posting of reviews."""
+ if not await self.autoreview_enabled():
+ await ctx.send(":x: Autoreview is already disabled")
+ return
+
+ await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, False)
+ self.reviewer.cancel_all()
+ await ctx.send(":white_check_mark: Autoreview disabled")
+
+ @nomination_autoreview_group.command(name="status")
+ @has_any_role(*MODERATION_ROLES)
+ async def autoreview_status(self, ctx: Context) -> None:
+ """Show whether automatic posting of reviews is enabled or disabled."""
+ if await self.autoreview_enabled():
+ await ctx.send("Autoreview is currently enabled")
+ else:
+ await ctx.send("Autoreview is currently disabled")
+
@nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",))
@has_any_role(*MODERATION_ROLES)
async def watched_command(
@@ -190,7 +251,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
self.watched_users[user.id] = response_data
- if user.id not in self.reviewer:
+ if await self.autoreview_enabled() and user.id not in self.reviewer:
self.reviewer.schedule_review(user.id)
history = await self.bot.api_client.get(
@@ -403,7 +464,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
)
self._remove_user(user_id)
- self.reviewer.cancel(user_id)
+ if await self.autoreview_enabled():
+ self.reviewer.cancel(user_id)
return True
diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py
index 3a1e66970..4d496a1f7 100644
--- a/bot/exts/recruitment/talentpool/_review.py
+++ b/bot/exts/recruitment/talentpool/_review.py
@@ -15,7 +15,7 @@ from discord.ext.commands import Context
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Channels, Colours, Emojis, Guild, Roles
+from bot.constants import Channels, Colours, Emojis, Guild
from bot.utils.messages import count_unique_users_reaction, pin_no_system_message
from bot.utils.scheduling import Scheduler
from bot.utils.time import get_time_delta, time_since
@@ -33,10 +33,12 @@ MAX_MESSAGE_SIZE = 2000
# Maximum amount of characters allowed in an embed
MAX_EMBED_SIZE = 4000
-# Regex finding the user ID of a user mention
-MENTION_RE = re.compile(r"<@!?(\d+?)>")
-# Regex matching role pings
-ROLE_MENTION_RE = re.compile(r"<@&\d+>")
+# Regex for finding the first message of a nomination, and extracting the nominee.
+# Historic nominations will have 2 role mentions at the start, new ones won't, optionally match for this.
+NOMINATION_MESSAGE_REGEX = re.compile(
+ r"(?:<@&\d+> <@&\d+>\n)*?<@!?(\d+?)> \(.+#\d{4}\) for Helper!\n\n\*\*Nominated by:\*\*",
+ re.MULTILINE
+)
class Reviewer:
@@ -118,7 +120,7 @@ class Reviewer:
f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server :pensive:"
), None
- opening = f"<@&{Roles.mod_team}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!"
+ opening = f"{member.mention} ({member}) for Helper!"
current_nominations = "\n\n".join(
f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}"
@@ -142,14 +144,14 @@ class Reviewer:
"""Archive this vote to #nomination-archive."""
message = await message.fetch()
- # We consider the first message in the nomination to contain the two role pings
+ # We consider the first message in the nomination to contain the user ping, username#discrim, and fixed text
messages = [message]
- if not len(ROLE_MENTION_RE.findall(message.content)) >= 2:
+ if not NOMINATION_MESSAGE_REGEX.search(message.content):
with contextlib.suppress(NoMoreItems):
async for new_message in message.channel.history(before=message.created_at):
messages.append(new_message)
- if len(ROLE_MENTION_RE.findall(new_message.content)) >= 2:
+ if NOMINATION_MESSAGE_REGEX.search(new_message.content):
break
log.debug(f"Found {len(messages)} messages: {', '.join(str(m.id) for m in messages)}")
@@ -161,7 +163,7 @@ class Reviewer:
content = "".join(parts)
# We assume that the first user mentioned is the user that we are voting on
- user_id = int(MENTION_RE.search(content).group(1))
+ user_id = int(NOMINATION_MESSAGE_REGEX.search(content).group(1))
# Get reaction counts
reviewed = await count_unique_users_reaction(
diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py
deleted file mode 100644
index 87ae847f6..000000000
--- a/bot/exts/utils/jams.py
+++ /dev/null
@@ -1,176 +0,0 @@
-import csv
-import logging
-import typing as t
-from collections import defaultdict
-
-import discord
-from discord.ext import commands
-
-from bot.bot import Bot
-from bot.constants import Categories, Channels, Emojis, Roles
-
-log = logging.getLogger(__name__)
-
-MAX_CHANNELS = 50
-CATEGORY_NAME = "Code Jam"
-TEAM_LEADERS_COLOUR = 0x11806a
-
-
-class CodeJams(commands.Cog):
- """Manages the code-jam related parts of our server."""
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- @commands.group()
- @commands.has_any_role(Roles.admins)
- async def codejam(self, ctx: commands.Context) -> None:
- """A Group of commands for managing Code Jams."""
- if ctx.invoked_subcommand is None:
- await ctx.send_help(ctx.command)
-
- @codejam.command()
- async def create(self, ctx: commands.Context, csv_file: t.Optional[str]) -> None:
- """
- Create code-jam teams from a CSV file or a link to one, specifying the team names, leaders and members.
-
- The CSV file must have 3 columns: 'Team Name', 'Team Member Discord ID', and 'Team Leader'.
-
- This will create the text channels for the teams, and give the team leaders their roles.
- """
- async with ctx.typing():
- if csv_file:
- async with self.bot.http_session.get(csv_file) as response:
- if response.status != 200:
- await ctx.send(f"Got a bad response from the URL: {response.status}")
- return
-
- csv_file = await response.text()
-
- elif ctx.message.attachments:
- csv_file = (await ctx.message.attachments[0].read()).decode("utf8")
- else:
- raise commands.BadArgument("You must include either a CSV file or a link to one.")
-
- teams = defaultdict(list)
- reader = csv.DictReader(csv_file.splitlines())
-
- for row in reader:
- member = ctx.guild.get_member(int(row["Team Member Discord ID"]))
-
- if member is None:
- log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}")
- continue
-
- teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y"))
-
- team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR)
-
- for team_name, members in teams.items():
- await self.create_team_channel(ctx.guild, team_name, members, team_leaders)
-
- await self.create_team_leader_channel(ctx.guild, team_leaders)
- await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.")
-
- async def get_category(self, guild: discord.Guild) -> discord.CategoryChannel:
- """
- Return a code jam category.
-
- If all categories are full or none exist, create a new category.
- """
- for category in guild.categories:
- if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS:
- return category
-
- return await self.create_category(guild)
-
- async def create_category(self, guild: discord.Guild) -> discord.CategoryChannel:
- """Create a new code jam category and return it."""
- log.info("Creating a new code jam category.")
-
- category_overwrites = {
- guild.default_role: discord.PermissionOverwrite(read_messages=False),
- guild.me: discord.PermissionOverwrite(read_messages=True)
- }
-
- category = await guild.create_category_channel(
- CATEGORY_NAME,
- overwrites=category_overwrites,
- reason="It's code jam time!"
- )
-
- await self.send_status_update(
- guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels."
- )
-
- return category
-
- @staticmethod
- def get_overwrites(
- members: list[tuple[discord.Member, bool]],
- guild: discord.Guild,
- ) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]:
- """Get code jam team channels permission overwrites."""
- team_channel_overwrites = {
- guild.default_role: discord.PermissionOverwrite(read_messages=False),
- guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True)
- }
-
- for member, _ in members:
- team_channel_overwrites[member] = discord.PermissionOverwrite(
- read_messages=True
- )
-
- return team_channel_overwrites
-
- async def create_team_channel(
- self,
- guild: discord.Guild,
- team_name: str,
- members: list[tuple[discord.Member, bool]],
- team_leaders: discord.Role
- ) -> None:
- """Create the team's text channel."""
- await self.add_team_leader_roles(members, team_leaders)
-
- # Get permission overwrites and category
- team_channel_overwrites = self.get_overwrites(members, guild)
- code_jam_category = await self.get_category(guild)
-
- # Create a text channel for the team
- await code_jam_category.create_text_channel(
- team_name,
- overwrites=team_channel_overwrites,
- )
-
- async def create_team_leader_channel(self, guild: discord.Guild, team_leaders: discord.Role) -> None:
- """Create the Team Leader Chat channel for the Code Jam team leaders."""
- category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam)
-
- team_leaders_chat = await category.create_text_channel(
- name="team-leaders-chat",
- overwrites={
- guild.default_role: discord.PermissionOverwrite(read_messages=False),
- team_leaders: discord.PermissionOverwrite(read_messages=True)
- }
- )
-
- await self.send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.")
-
- async def send_status_update(self, guild: discord.Guild, message: str) -> None:
- """Inform the events lead with a status update when the command is ran."""
- channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning)
-
- await channel.send(f"<@&{Roles.events_lead}>\n\n{message}")
-
- @staticmethod
- async def add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None:
- """Assign team leader role, the jammer role and their team role."""
- for member, is_leader in members:
- if is_leader:
- await member.add_roles(team_leaders)
-
-
-def setup(bot: Bot) -> None:
- """Load the CodeJams cog."""
- bot.add_cog(CodeJams(bot))
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py
index 7b8c5c4b3..441b0353f 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -181,7 +181,7 @@ class Reminders(Cog):
)
# Let's not use a codeblock to keep emojis and mentions working. Embeds are safe anyway.
- embed.description = f"Here's your reminder: {reminder['content']}."
+ embed.description = f"Here's your reminder: {reminder['content']}"
if reminder.get("jump_url"): # keep backward compatibility
embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})"
diff --git a/bot/pagination.py b/bot/pagination.py
index 90d7c84ee..26caa7db0 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -75,7 +75,7 @@ class LinePaginator(Paginator):
raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})")
if scale_to_size > 4000:
- raise ValueError(f"scale_to_size must be <= 2,000 characters. ({scale_to_size} > 4000)")
+ raise ValueError(f"scale_to_size must be <= 4,000 characters. ({scale_to_size} > 4000)")
self.scale_to_size = scale_to_size - len(suffix)
self.max_lines = max_lines
diff --git a/bot/resources/tags/blocking.md b/bot/resources/tags/blocking.md
index 31d91294c..5554d7eba 100644
--- a/bot/resources/tags/blocking.md
+++ b/bot/resources/tags/blocking.md
@@ -1,9 +1,7 @@
**Why do we need asynchronous programming?**
-
Imagine that you're coding a Discord bot and every time somebody uses a command, you need to get some information from a database. But there's a catch: the database servers are acting up today and take a whole 10 seconds to respond. If you do **not** use asynchronous methods, your whole bot will stop running until it gets a response from the database. How do you fix this? Asynchronous programming.
**What is asynchronous programming?**
-
An asynchronous program utilises the `async` and `await` keywords. An asynchronous program pauses what it's doing and does something else whilst it waits for some third-party service to complete whatever it's supposed to do. Any code within an `async` context manager or function marked with the `await` keyword indicates to Python, that whilst this operation is being completed, it can do something else. For example:
```py
@@ -14,13 +12,10 @@ import discord
async def ping(ctx):
await ctx.send("Pong!")
```
-
**What does the term "blocking" mean?**
-
A blocking operation is wherever you do something without `await`ing it. This tells Python that this step must be completed before it can do anything else. Common examples of blocking operations, as simple as they may seem, include: outputting text, adding two numbers and appending an item onto a list. Most common Python libraries have an asynchronous version available to use in asynchronous contexts.
**`async` libraries**
-
The standard async library - `asyncio`
Asynchronous web requests - `aiohttp`
Talking to PostgreSQL asynchronously - `asyncpg`
diff --git a/bot/resources/tags/modmail.md b/bot/resources/tags/modmail.md
index 412468174..8ac19c8a7 100644
--- a/bot/resources/tags/modmail.md
+++ b/bot/resources/tags/modmail.md
@@ -6,4 +6,4 @@ It supports attachments, codeblocks, and reactions. As communication happens ove
**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 <@&831776746206265384> or <@&267628507062992896> role instead.
+Should there be an urgent and immediate need for a moderator to look at a channel, feel free to ping the <@&831776746206265384> role instead.
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index 90672fba2..abeb04021 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -7,8 +7,6 @@ from io import BytesIO
from typing import Callable, List, Optional, Sequence, Union
import discord
-from discord import Message, MessageType, Reaction, User
-from discord.errors import HTTPException
from discord.ext.commands import Context
import bot
@@ -53,7 +51,7 @@ def reaction_check(
log.trace(f"Removing reaction {reaction} by {user} on {reaction.message.id}: disallowed user.")
scheduling.create_task(
reaction.message.remove_reaction(reaction.emoji, user),
- suppressed_exceptions=(HTTPException,),
+ suppressed_exceptions=(discord.HTTPException,),
name=f"remove_reaction-{reaction}-{reaction.message.id}-{user}"
)
return False
@@ -97,11 +95,14 @@ async def wait_for_deletion(
)
try:
- await bot.instance.wait_for('reaction_add', check=check, timeout=timeout)
- except asyncio.TimeoutError:
- await message.clear_reactions()
- else:
- await message.delete()
+ try:
+ await bot.instance.wait_for('reaction_add', check=check, timeout=timeout)
+ except asyncio.TimeoutError:
+ await message.clear_reactions()
+ else:
+ await message.delete()
+ except discord.NotFound:
+ log.trace(f"wait_for_deletion: message {message.id} deleted prematurely.")
async def send_attachments(
@@ -150,7 +151,7 @@ async def send_attachments(
large.append(attachment)
else:
log.info(f"{failure_msg} because it's too large.")
- except HTTPException as e:
+ except discord.HTTPException as e:
if link_large and e.status == 413:
large.append(attachment)
else:
@@ -171,8 +172,8 @@ async def send_attachments(
async def count_unique_users_reaction(
message: discord.Message,
- reaction_predicate: Callable[[Reaction], bool] = lambda _: True,
- user_predicate: Callable[[User], bool] = lambda _: True,
+ reaction_predicate: Callable[[discord.Reaction], bool] = lambda _: True,
+ user_predicate: Callable[[discord.User], bool] = lambda _: True,
count_bots: bool = True
) -> int:
"""
@@ -192,7 +193,7 @@ async def count_unique_users_reaction(
return len(unique_users)
-async def pin_no_system_message(message: Message) -> bool:
+async def pin_no_system_message(message: discord.Message) -> bool:
"""Pin the given message, wait a couple of seconds and try to delete the system message."""
await message.pin()
@@ -200,7 +201,7 @@ async def pin_no_system_message(message: Message) -> bool:
await asyncio.sleep(2)
# Search for the system message in the last 10 messages
async for historical_message in message.channel.history(limit=10):
- if historical_message.type == MessageType.pins_add:
+ if historical_message.type == discord.MessageType.pins_add:
await historical_message.delete()
return True
diff --git a/tests/bot/exts/events/__init__.py b/tests/bot/exts/events/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/events/__init__.py
diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/events/test_code_jams.py
index 368a15476..b9ee1e363 100644
--- a/tests/bot/exts/utils/test_jams.py
+++ b/tests/bot/exts/events/test_code_jams.py
@@ -1,14 +1,15 @@
import unittest
-from unittest.mock import AsyncMock, MagicMock, create_autospec
+from unittest.mock import AsyncMock, MagicMock, create_autospec, patch
from discord import CategoryChannel
from discord.ext.commands import BadArgument
from bot.constants import Roles
-from bot.exts.utils import jams
+from bot.exts.events import code_jams
+from bot.exts.events.code_jams import _channels, _cog
from tests.helpers import (
MockAttachment, MockBot, MockCategoryChannel, MockContext,
- MockGuild, MockMember, MockRole, MockTextChannel
+ MockGuild, MockMember, MockRole, MockTextChannel, autospec
)
TEST_CSV = b"""\
@@ -40,7 +41,7 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase):
self.command_user = MockMember([self.admin_role])
self.guild = MockGuild([self.admin_role])
self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild)
- self.cog = jams.CodeJams(self.bot)
+ self.cog = _cog.CodeJams(self.bot)
async def test_message_without_attachments(self):
"""If no link or attachments are provided, commands.BadArgument should be raised."""
@@ -49,7 +50,9 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase):
with self.assertRaises(BadArgument):
await self.cog.create(self.cog, self.ctx, None)
- async def test_result_sending(self):
+ @patch.object(_channels, "create_team_channel")
+ @patch.object(_channels, "create_team_leader_channel")
+ async def test_result_sending(self, create_leader_channel, create_team_channel):
"""Should call `ctx.send` when everything goes right."""
self.ctx.message.attachments = [MockAttachment()]
self.ctx.message.attachments[0].read = AsyncMock()
@@ -61,14 +64,12 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase):
self.ctx.guild.create_role = AsyncMock()
self.ctx.guild.create_role.return_value = team_leaders
- self.cog.create_team_channel = AsyncMock()
- self.cog.create_team_leader_channel = AsyncMock()
self.cog.add_roles = AsyncMock()
await self.cog.create(self.cog, self.ctx, None)
- self.cog.create_team_channel.assert_awaited()
- self.cog.create_team_leader_channel.assert_awaited_once_with(
+ create_team_channel.assert_awaited()
+ create_leader_channel.assert_awaited_once_with(
self.ctx.guild, team_leaders
)
self.ctx.send.assert_awaited_once()
@@ -81,25 +82,24 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase):
self.ctx.send.assert_awaited_once()
- async def test_category_doesnt_exist(self):
+ @patch.object(_channels, "_send_status_update")
+ async def test_category_doesnt_exist(self, update):
"""Should create a new code jam category."""
subtests = (
[],
- [get_mock_category(jams.MAX_CHANNELS, jams.CATEGORY_NAME)],
- [get_mock_category(jams.MAX_CHANNELS - 2, "other")],
+ [get_mock_category(_channels.MAX_CHANNELS, _channels.CATEGORY_NAME)],
+ [get_mock_category(_channels.MAX_CHANNELS - 2, "other")],
)
- self.cog.send_status_update = AsyncMock()
-
for categories in subtests:
- self.cog.send_status_update.reset_mock()
+ update.reset_mock()
self.guild.reset_mock()
self.guild.categories = categories
with self.subTest(categories=categories):
- actual_category = await self.cog.get_category(self.guild)
+ actual_category = await _channels._get_category(self.guild)
- self.cog.send_status_update.assert_called_once()
+ update.assert_called_once()
self.guild.create_category_channel.assert_awaited_once()
category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"]
@@ -109,45 +109,41 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase):
async def test_category_channel_exist(self):
"""Should not try to create category channel."""
- expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME)
+ expected_category = get_mock_category(_channels.MAX_CHANNELS - 2, _channels.CATEGORY_NAME)
self.guild.categories = [
- get_mock_category(jams.MAX_CHANNELS - 2, "other"),
+ get_mock_category(_channels.MAX_CHANNELS - 2, "other"),
expected_category,
- get_mock_category(0, jams.CATEGORY_NAME),
+ get_mock_category(0, _channels.CATEGORY_NAME),
]
- actual_category = await self.cog.get_category(self.guild)
+ actual_category = await _channels._get_category(self.guild)
self.assertEqual(expected_category, actual_category)
async def test_channel_overwrites(self):
"""Should have correct permission overwrites for users and roles."""
leader = (MockMember(), True)
members = [leader] + [(MockMember(), False) for _ in range(4)]
- overwrites = self.cog.get_overwrites(members, self.guild)
+ overwrites = _channels._get_overwrites(members, self.guild)
for member, _ in members:
self.assertTrue(overwrites[member].read_messages)
- async def test_team_channels_creation(self):
+ @patch.object(_channels, "_get_overwrites")
+ @patch.object(_channels, "_get_category")
+ @autospec(_channels, "_add_team_leader_roles", pass_mocks=False)
+ async def test_team_channels_creation(self, get_category, get_overwrites):
"""Should create a text channel for a team."""
team_leaders = MockRole()
members = [(MockMember(), True)] + [(MockMember(), False) for _ in range(5)]
category = MockCategoryChannel()
category.create_text_channel = AsyncMock()
- self.cog.get_overwrites = MagicMock()
- self.cog.get_category = AsyncMock()
- self.cog.get_category.return_value = category
- self.cog.add_team_leader_roles = AsyncMock()
-
- await self.cog.create_team_channel(self.guild, "my-team", members, team_leaders)
- self.cog.add_team_leader_roles.assert_awaited_once_with(members, team_leaders)
- self.cog.get_overwrites.assert_called_once_with(members, self.guild)
- self.cog.get_category.assert_awaited_once_with(self.guild)
+ get_category.return_value = category
+ await _channels.create_team_channel(self.guild, "my-team", members, team_leaders)
category.create_text_channel.assert_awaited_once_with(
"my-team",
- overwrites=self.cog.get_overwrites.return_value
+ overwrites=get_overwrites.return_value
)
async def test_jam_roles_adding(self):
@@ -156,7 +152,7 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase):
leader = MockMember()
members = [(leader, True)] + [(MockMember(), False) for _ in range(4)]
- await self.cog.add_team_leader_roles(members, leader_role)
+ await _channels._add_team_leader_roles(members, leader_role)
leader.add_roles.assert_awaited_once_with(leader_role)
for member, is_leader in members:
@@ -170,5 +166,5 @@ class CodeJamSetup(unittest.TestCase):
def test_setup(self):
"""Should call `bot.add_cog`."""
bot = MockBot()
- jams.setup(bot)
+ code_jams.setup(bot)
bot.add_cog.assert_called_once()