diff options
-rw-r--r-- | bot/constants.py | 11 | ||||
-rw-r--r-- | bot/exts/events/__init__.py | 0 | ||||
-rw-r--r-- | bot/exts/events/code_jams/__init__.py | 8 | ||||
-rw-r--r-- | bot/exts/events/code_jams/_channels.py | 113 | ||||
-rw-r--r-- | bot/exts/events/code_jams/_cog.py | 239 | ||||
-rw-r--r-- | bot/rules/mentions.py | 70 | ||||
-rw-r--r-- | tests/bot/exts/events/__init__.py | 0 | ||||
-rw-r--r-- | tests/bot/exts/events/test_code_jams.py | 170 |
8 files changed, 0 insertions, 611 deletions
diff --git a/bot/constants.py b/bot/constants.py index eefae11e2..4b3f6a6a7 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -326,17 +326,6 @@ class _Colours(EnvConfig): Colours = _Colours() -class _Free(EnvConfig): - EnvConfig.Config.env_prefix = "free_" - - activity_timeout = 600 - cooldown_per = 60.0 - cooldown_rate = 1 - - -Free = _Free() - - class _HelpChannels(EnvConfig): EnvConfig.Config.env_prefix = "help_channels_" diff --git a/bot/exts/events/__init__.py b/bot/exts/events/__init__.py deleted file mode 100644 index e69de29bb..000000000 --- a/bot/exts/events/__init__.py +++ /dev/null diff --git a/bot/exts/events/code_jams/__init__.py b/bot/exts/events/code_jams/__init__.py deleted file mode 100644 index 2f858d1f9..000000000 --- a/bot/exts/events/code_jams/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from bot.bot import Bot - - -async def setup(bot: Bot) -> None: - """Load the CodeJams cog.""" - from bot.exts.events.code_jams._cog import CodeJams - - await bot.add_cog(CodeJams(bot)) diff --git a/bot/exts/events/code_jams/_channels.py b/bot/exts/events/code_jams/_channels.py deleted file mode 100644 index e8cf5f7bf..000000000 --- a/bot/exts/events/code_jams/_channels.py +++ /dev/null @@ -1,113 +0,0 @@ -import typing as t - -import discord - -from bot.constants import Categories, Channels, Roles -from bot.log import get_logger - -log = get_logger(__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 deleted file mode 100644 index 86c357863..000000000 --- a/bot/exts/events/code_jams/_cog.py +++ /dev/null @@ -1,239 +0,0 @@ -import asyncio -import csv -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.log import get_logger -from bot.utils.members import get_or_fetch_member -from bot.utils.services import PasteTooLongError, PasteUploadError, send_to_paste_service - -log = get_logger(__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 = await get_or_fetch_member(ctx.guild, 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, team_members in teams.items(): - await _channels.create_team_channel(ctx.guild, team_name, team_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() - ) - - try: - message = await send_to_paste_service(deletion_details) - except PasteTooLongError: - message = "**Too long to upload to paste service.**" - except PasteUploadError: - message = "**Failed to upload to paste service.**" - - return f"Are you sure you want to delete all code jam channels?\n\nThe channels to be deleted: {message}" - - @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.og_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/rules/mentions.py b/bot/rules/mentions.py deleted file mode 100644 index ca1d0c01c..000000000 --- a/bot/rules/mentions.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import Dict, Iterable, List, Optional, Tuple - -from discord import DeletedReferencedMessage, Member, Message, MessageType, NotFound - -import bot -from bot.log import get_logger - -log = get_logger(__name__) - - -async def apply( - last_message: Message, recent_messages: List[Message], config: Dict[str, int] -) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """ - Detects total mentions exceeding the limit sent by a single user. - - Excludes mentions that are bots, themselves, or replied users. - - In very rare cases, may not be able to determine a - mention was to a reply, in which case it is not ignored. - """ - relevant_messages = tuple( - msg - for msg in recent_messages - if msg.author == last_message.author - ) - # We use `msg.mentions` here as that is supplied by the api itself, to determine who was mentioned. - # Additionally, `msg.mentions` includes the user replied to, even if the mention doesn't occur in the body. - # In order to exclude users who are mentioned as a reply, we check if the msg has a reference - # - # While we could use regex to parse the message content, and get a list of - # the mentions, that solution is very prone to breaking. - # We would need to deal with codeblocks, escaping markdown, and any discrepancies between - # our implementation and discord's markdown parser which would cause false positives or false negatives. - total_recent_mentions = 0 - for msg in relevant_messages: - # We check if the message is a reply, and if it is try to get the author - # since we ignore mentions of a user that we're replying to - reply_author = None - - if msg.type == MessageType.reply: - ref = msg.reference - - if not (resolved := ref.resolved): - # It is possible, in a very unusual situation, for a message to have a reference - # that is both not in the cache, and deleted while running this function. - # In such a situation, this will throw an error which we catch. - try: - resolved = await bot.instance.get_partial_messageable(resolved.channel_id).fetch_message( - resolved.message_id - ) - except NotFound: - log.info('Could not fetch the reference message as it has been deleted.') - - if resolved and not isinstance(resolved, DeletedReferencedMessage): - reply_author = resolved.author - - for user in msg.mentions: - # Don't count bot or self mentions, or the user being replied to (if applicable) - if user.bot or user in {msg.author, reply_author}: - continue - total_recent_mentions += 1 - - if total_recent_mentions > config['max']: - return ( - f"sent {total_recent_mentions} mentions in {config['interval']}s", - (last_message.author,), - relevant_messages - ) - return None diff --git a/tests/bot/exts/events/__init__.py b/tests/bot/exts/events/__init__.py deleted file mode 100644 index e69de29bb..000000000 --- a/tests/bot/exts/events/__init__.py +++ /dev/null diff --git a/tests/bot/exts/events/test_code_jams.py b/tests/bot/exts/events/test_code_jams.py deleted file mode 100644 index 684f7abcd..000000000 --- a/tests/bot/exts/events/test_code_jams.py +++ /dev/null @@ -1,170 +0,0 @@ -import unittest -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.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, - autospec -) - -TEST_CSV = b"""\ -Team Name,Team Member Discord ID,Team Leader -Annoyed Alligators,12345,Y -Annoyed Alligators,54321,N -Oscillating Otters,12358,Y -Oscillating Otters,74832,N -Oscillating Otters,19903,N -Annoyed Alligators,11111,N -""" - - -def get_mock_category(channel_count: int, name: str) -> CategoryChannel: - """Return a mocked code jam category.""" - category = create_autospec(CategoryChannel, spec_set=True, instance=True) - category.name = name - category.channels = [MockTextChannel() for _ in range(channel_count)] - - return category - - -class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): - """Tests for `codejam create` command.""" - - def setUp(self): - self.bot = MockBot() - self.admin_role = MockRole(name="Admins", id=Roles.admins) - 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 = _cog.CodeJams(self.bot) - - async def test_message_without_attachments(self): - """If no link or attachments are provided, commands.BadArgument should be raised.""" - self.ctx.message.attachments = [] - - with self.assertRaises(BadArgument): - await self.cog.create(self.cog, self.ctx, None) - - @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() - self.ctx.message.attachments[0].read.return_value = TEST_CSV - - team_leaders = MockRole() - - self.guild.get_member.return_value = MockMember() - - self.ctx.guild.create_role = AsyncMock() - self.ctx.guild.create_role.return_value = team_leaders - self.cog.add_roles = AsyncMock() - - await self.cog.create(self.cog, self.ctx, None) - - create_team_channel.assert_awaited() - create_leader_channel.assert_awaited_once_with( - self.ctx.guild, team_leaders - ) - self.ctx.send.assert_awaited_once() - - async def test_link_returning_non_200_status(self): - """When the URL passed returns a non 200 status, it should send a message informing them.""" - self.bot.http_session.get.return_value = mock = MagicMock() - mock.status = 404 - await self.cog.create(self.cog, self.ctx, "https://not-a-real-link.com") - - self.ctx.send.assert_awaited_once() - - @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(_channels.MAX_CHANNELS, _channels.CATEGORY_NAME)], - [get_mock_category(_channels.MAX_CHANNELS - 2, "other")], - ) - - for categories in subtests: - update.reset_mock() - self.guild.reset_mock() - self.guild.categories = categories - - with self.subTest(categories=categories): - actual_category = await _channels._get_category(self.guild) - - update.assert_called_once() - self.guild.create_category_channel.assert_awaited_once() - category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] - - self.assertFalse(category_overwrites[self.guild.default_role].read_messages) - self.assertTrue(category_overwrites[self.guild.me].read_messages) - self.assertEqual(self.guild.create_category_channel.return_value, actual_category) - - async def test_category_channel_exist(self): - """Should not try to create category channel.""" - expected_category = get_mock_category(_channels.MAX_CHANNELS - 2, _channels.CATEGORY_NAME) - self.guild.categories = [ - get_mock_category(_channels.MAX_CHANNELS - 2, "other"), - expected_category, - get_mock_category(0, _channels.CATEGORY_NAME), - ] - - 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 = _channels._get_overwrites(members, self.guild) - - for member, _ in members: - self.assertTrue(overwrites[member].read_messages) - - @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() - - 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=get_overwrites.return_value - ) - - async def test_jam_roles_adding(self): - """Should add team leader role to leader and jam role to every team member.""" - leader_role = MockRole(name="Team Leader") - - leader = MockMember() - members = [(leader, True)] + [(MockMember(), False) for _ in range(4)] - await _channels._add_team_leader_roles(members, leader_role) - - leader.add_roles.assert_awaited_once_with(leader_role) - for member, is_leader in members: - if not is_leader: - member.add_roles.assert_not_awaited() - - -class CodeJamSetup(unittest.IsolatedAsyncioTestCase): - """Test for `setup` function of `CodeJam` cog.""" - - async def test_setup(self): - """Should call `bot.add_cog`.""" - bot = MockBot() - await code_jams.setup(bot) - bot.add_cog.assert_awaited_once() |