diff options
| author | 2020-08-02 16:15:07 -0700 | |
|---|---|---|
| committer | 2020-08-02 16:15:07 -0700 | |
| commit | e8a58ba87be9a4bbd9059547b32276ebcf0e0a85 (patch) | |
| tree | 22c2985eaee5d02331f3f99a3ec5ca26f6c4809a | |
| parent | Fix test for token remover log message (diff) | |
| parent | Remove superfluous Available help channels. (diff) | |
Fix conflict with disabling filter ping for DMs
Diffstat (limited to '')
| -rw-r--r-- | bot/cogs/bot.py | 10 | ||||
| -rw-r--r-- | bot/cogs/filtering.py | 4 | ||||
| -rw-r--r-- | bot/cogs/help_channels.py | 47 | ||||
| -rw-r--r-- | bot/cogs/information.py | 5 | ||||
| -rw-r--r-- | bot/cogs/jams.py | 86 | ||||
| -rw-r--r-- | bot/cogs/utils.py | 40 | ||||
| -rw-r--r-- | bot/cogs/webhook_remover.py | 2 | ||||
| -rw-r--r-- | config-default.yml | 8 | ||||
| -rw-r--r-- | tests/bot/cogs/test_jams.py | 173 | 
9 files changed, 307 insertions, 68 deletions
| diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index a79b37d25..79510739c 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -72,10 +72,14 @@ class BotCog(Cog, name="Bot"):      @command(name='embed')      @with_role(*MODERATION_ROLES) -    async def embed_command(self, ctx: Context, *, text: str) -> None: -        """Send the input within an embed to the current channel.""" +    async def embed_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: +        """Send the input within an embed to either a specified channel or the current channel."""          embed = Embed(description=text) -        await ctx.send(embed=embed) + +        if channel is None: +            await ctx.send(embed=embed) +        else: +            await channel.send(embed=embed)      def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]:          """ diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 07a603988..67d4e6010 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -312,8 +312,10 @@ class Filtering(Cog):          """Send a mod log for a triggered filter."""          if msg.channel.type is discord.ChannelType.private:              channel_str = "via DM" +            ping_everyone = False          else:              channel_str = f"in {msg.channel.mention}" +            ping_everyone = Filter.ping_everyone          eval_msg = "using !eval" if is_eval else ""          message = ( @@ -332,7 +334,7 @@ class Filtering(Cog):              text=message,              thumbnail=msg.author.avatar_url_as(static_format="png"),              channel_id=Channels.mod_alerts, -            ping_everyone=Filter.ping_everyone, +            ping_everyone=ping_everyone,              additional_embeds=stats.additional_embeds,              additional_embeds_msg=stats.additional_embeds_msg          ) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 0c8cbb417..1be980472 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -102,6 +102,10 @@ class HelpChannels(commands.Cog):      # RedisCache[discord.TextChannel.id, UtcPosixTimestamp]      claim_times = RedisCache() +    # This cache maps a help channel to original question message in same channel. +    # RedisCache[discord.TextChannel.id, discord.Message.id] +    question_messages = RedisCache() +      def __init__(self, bot: Bot):          self.bot = bot          self.scheduler = Scheduler(self.__class__.__name__) @@ -360,10 +364,18 @@ class HelpChannels(commands.Cog):          channels = list(self.get_category_channels(self.available_category))          missing = constants.HelpChannels.max_available - len(channels) -        log.trace(f"Moving {missing} missing channels to the Available category.") +        # If we've got less than `max_available` channel available, we should add some. +        if missing > 0: +            log.trace(f"Moving {missing} missing channels to the Available category.") +            for _ in range(missing): +                await self.move_to_available() -        for _ in range(missing): -            await self.move_to_available() +        # If for some reason we have more than `max_available` channels available, +        # we should move the superfluous ones over to dormant. +        elif missing < 0: +            log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") +            for channel in channels[:abs(missing)]: +                await self.move_to_dormant(channel, "auto")      async def init_categories(self) -> None:          """Get the help category objects. Remove the cog if retrieval fails.""" @@ -428,8 +440,11 @@ class HelpChannels(commands.Cog):          if not message or not message.embeds:              return False -        embed = message.embeds[0] -        return message.author == self.bot.user and embed.description.strip() == description.strip() +        bot_msg_desc = message.embeds[0].description +        if bot_msg_desc is discord.Embed.Empty: +            log.trace("Last message was a bot embed but it was empty.") +            return False +        return message.author == self.bot.user and bot_msg_desc.strip() == description.strip()      @staticmethod      def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: @@ -536,6 +551,18 @@ class HelpChannels(commands.Cog):          A caller argument is provided for metrics.          """ +        msg_id = await self.question_messages.pop(channel.id) + +        try: +            await self.bot.http.unpin_message(channel.id, msg_id) +        except discord.HTTPException as e: +            if e.code == 10008: +                log.trace(f"Message {msg_id} don't exist, can't unpin.") +            else: +                log.warn(f"Got unexpected status {e.code} when unpinning message {msg_id}: {e.text}") +        else: +            log.trace(f"Unpinned message {msg_id}.") +          log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.")          await self.move_to_bottom_position( @@ -677,6 +704,16 @@ class HelpChannels(commands.Cog):              log.info(f"Channel #{channel} was claimed by `{message.author.id}`.")              await self.move_to_in_use(channel)              await self.revoke_send_permissions(message.author) +            # Pin message for better access and store this to cache +            try: +                await message.pin() +            except discord.NotFound: +                log.info(f"Pinning message {message.id} ({channel}) failed because message got deleted.") +            except discord.HTTPException as e: +                log.info(f"Pinning message {message.id} ({channel.id}) failed with code {e.code}", exc_info=e) +            else: +                await self.question_messages.set(channel.id, message.id) +              # Add user with channel for dormant check.              await self.help_channel_claimants.set(channel.id, message.author.id) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index d6090d481..8982196d1 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -116,10 +116,7 @@ class Information(Cog):              parsed_roles.append(role)          if failed_roles: -            await ctx.send( -                ":x: I could not convert the following role names to a role: \n- " -                "\n- ".join(failed_roles) -            ) +            await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}")          for role in parsed_roles:              h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb()) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 1d062b0c2..b3102db2f 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -1,6 +1,7 @@  import logging +import typing as t -from discord import Member, PermissionOverwrite, utils +from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role  from discord.ext import commands  from more_itertools import unique_everseen @@ -10,6 +11,9 @@ from bot.decorators import with_role  log = logging.getLogger(__name__) +MAX_CHANNELS = 50 +CATEGORY_NAME = "Code Jam" +  class CodeJams(commands.Cog):      """Manages the code-jam related parts of our server.""" @@ -40,22 +44,47 @@ class CodeJams(commands.Cog):              )              return -        code_jam_category = utils.get(ctx.guild.categories, name="Code Jam") +        team_channel = await self.create_channels(ctx.guild, team_name, members) +        await self.add_roles(ctx.guild, members) -        if code_jam_category is None: -            log.info("Code Jam category not found, creating it.") +        await ctx.send( +            f":ok_hand: Team created: {team_channel}\n" +            f"**Team Leader:** {members[0].mention}\n" +            f"**Team Members:** {' '.join(member.mention for member in members[1:])}" +        ) -            category_overwrites = { -                ctx.guild.default_role: PermissionOverwrite(read_messages=False), -                ctx.guild.me: PermissionOverwrite(read_messages=True) -            } +    async def get_category(self, guild: Guild) -> CategoryChannel: +        """ +        Return a code jam category. -            code_jam_category = await ctx.guild.create_category_channel( -                "Code Jam", -                overwrites=category_overwrites, -                reason="It's code jam time!" -            ) +        If all categories are full or none exist, create a new category. +        """ +        for category in guild.categories: +            # Need 2 available spaces: one for the text channel and one for voice. +            if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2: +                return category + +        return await self.create_category(guild) + +    @staticmethod +    async def create_category(guild: Guild) -> CategoryChannel: +        """Create a new code jam category and return it.""" +        log.info("Creating a new code jam category.") + +        category_overwrites = { +            guild.default_role: PermissionOverwrite(read_messages=False), +            guild.me: PermissionOverwrite(read_messages=True) +        } + +        return await guild.create_category_channel( +            CATEGORY_NAME, +            overwrites=category_overwrites, +            reason="It's code jam time!" +        ) +    @staticmethod +    def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]: +        """Get code jam team channels permission overwrites."""          # First member is always the team leader          team_channel_overwrites = {              members[0]: PermissionOverwrite( @@ -64,8 +93,8 @@ class CodeJams(commands.Cog):                  manage_webhooks=True,                  connect=True              ), -            ctx.guild.default_role: PermissionOverwrite(read_messages=False, connect=False), -            ctx.guild.get_role(Roles.verified): PermissionOverwrite( +            guild.default_role: PermissionOverwrite(read_messages=False, connect=False), +            guild.get_role(Roles.verified): PermissionOverwrite(                  read_messages=False,                  connect=False              ) @@ -78,8 +107,16 @@ class CodeJams(commands.Cog):                  connect=True              ) +        return team_channel_overwrites + +    async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str: +        """Create team text and voice channels. Return the mention for the text channel.""" +        # 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 -        team_channel = await ctx.guild.create_text_channel( +        team_channel = await guild.create_text_channel(              team_name,              overwrites=team_channel_overwrites,              category=code_jam_category @@ -88,26 +125,25 @@ class CodeJams(commands.Cog):          # Create a voice channel for the team          team_voice_name = " ".join(team_name.split("-")).title() -        await ctx.guild.create_voice_channel( +        await guild.create_voice_channel(              team_voice_name,              overwrites=team_channel_overwrites,              category=code_jam_category          ) +        return team_channel.mention + +    @staticmethod +    async def add_roles(guild: Guild, members: t.List[Member]) -> None: +        """Assign team leader and jammer roles."""          # Assign team leader role -        await members[0].add_roles(ctx.guild.get_role(Roles.team_leaders)) +        await members[0].add_roles(guild.get_role(Roles.team_leaders))          # Assign rest of roles -        jammer_role = ctx.guild.get_role(Roles.jammers) +        jammer_role = guild.get_role(Roles.jammers)          for member in members:              await member.add_roles(jammer_role) -        await ctx.send( -            f":ok_hand: Team created: {team_channel.mention}\n" -            f"**Team Leader:** {members[0].mention}\n" -            f"**Team Members:** {' '.join(member.mention for member in members[1:])}" -        ) -  def setup(bot: Bot) -> None:      """Load the CodeJams cog.""" diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 697bf60ce..91c6cb36e 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -7,11 +7,13 @@ from io import StringIO  from typing import Tuple, Union  from discord import Colour, Embed, utils -from discord.ext.commands import BadArgument, Cog, Context, command +from discord.ext.commands import BadArgument, Cog, Context, clean_content, command  from bot.bot import Bot  from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES  from bot.decorators import in_whitelist, with_role +from bot.pagination import LinePaginator +from bot.utils import messages  log = logging.getLogger(__name__) @@ -117,25 +119,18 @@ class Utils(Cog):      @command()      @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES)      async def charinfo(self, ctx: Context, *, characters: str) -> None: -        """Shows you information on up to 25 unicode characters.""" +        """Shows you information on up to 50 unicode characters."""          match = re.match(r"<(a?):(\w+):(\d+)>", characters)          if match: -            embed = Embed( -                title="Non-Character Detected", -                description=( -                    "Only unicode characters can be processed, but a custom Discord emoji " -                    "was found. Please remove it and try again." -                ) +            return await messages.send_denial( +                ctx, +                "**Non-Character Detected**\n" +                "Only unicode characters can be processed, but a custom Discord emoji " +                "was found. Please remove it and try again."              ) -            embed.colour = Colour.red() -            await ctx.send(embed=embed) -            return -        if len(characters) > 25: -            embed = Embed(title=f"Too many characters ({len(characters)}/25)") -            embed.colour = Colour.red() -            await ctx.send(embed=embed) -            return +        if len(characters) > 50: +            return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)")          def get_info(char: str) -> Tuple[str, str]:              digit = f"{ord(char):x}" @@ -148,15 +143,14 @@ class Utils(Cog):              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)) - -        embed = Embed(description="\n".join(charlist)) -        embed.set_author(name="Character Info") +        char_list, raw_list = zip(*(get_info(c) for c in characters)) +        embed = Embed().set_author(name="Character Info")          if len(characters) > 1: -            embed.add_field(name='Raw', value=f"`{''.join(rawlist)}`", inline=False) +            # Maximum length possible is 502 out of 1024, so there's no need to truncate. +            embed.add_field(name='Full Raw Text', value=f"`{''.join(raw_list)}`", inline=False) -        await ctx.send(embed=embed) +        await LinePaginator.paginate(char_list, ctx, embed, max_lines=10, max_size=2000, empty=False)      @command()      async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None: @@ -231,7 +225,7 @@ class Utils(Cog):      @command(aliases=("poll",))      @with_role(*MODERATION_ROLES) -    async def vote(self, ctx: Context, title: str, *options: str) -> None: +    async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None:          """          Build a quick voting poll with matching reactions with the provided options. diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index b70e29a79..d87664e85 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -9,7 +9,7 @@ from bot.cogs.moderation.modlog import ModLog  from bot.constants import Channels, Colours, Event, Icons  from bot.utils.messages import format_user -WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discordapp\.com/api/webhooks/\d+/)\S+/?", re.I) +WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE)  ALERT_MESSAGE_TEMPLATE = (      "{user}, looks like you posted a Discord webhook URL. Therefore, your " diff --git a/config-default.yml b/config-default.yml index ad6149f6f..fc093cc32 100644 --- a/config-default.yml +++ b/config-default.yml @@ -236,8 +236,8 @@ guild:          owners:             &OWNERS_ROLE    267627879762755584          # Code Jam -        jammers:        591786436651646989 -        team_leaders:   501324292341104650 +        jammers:        737249140966162473 +        team_leaders:   737250302834638889      moderation_roles:          - *OWNERS_ROLE @@ -459,10 +459,6 @@ anti_spam:              interval: 10              max: 7 -        burst_shared: -            interval: 10 -            max: 20 -          chars:              interval: 5              max: 3_000 diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py new file mode 100644 index 000000000..b4ad8535f --- /dev/null +++ b/tests/bot/cogs/test_jams.py @@ -0,0 +1,173 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, create_autospec + +from discord import CategoryChannel + +from bot.cogs import jams +from bot.constants import Roles +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel + + +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 JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): +    """Tests for `createteam` 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 = jams.CodeJams(self.bot) + +    async def test_too_small_amount_of_team_members_passed(self): +        """Should `ctx.send` and exit early when too small amount of members.""" +        for case in (1, 2): +            with self.subTest(amount_of_members=case): +                self.cog.create_channels = AsyncMock() +                self.cog.add_roles = AsyncMock() + +                self.ctx.reset_mock() +                members = (MockMember() for _ in range(case)) +                await self.cog.createteam(self.cog, self.ctx, "foo", members) + +                self.ctx.send.assert_awaited_once() +                self.cog.create_channels.assert_not_awaited() +                self.cog.add_roles.assert_not_awaited() + +    async def test_duplicate_members_provided(self): +        """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" +        self.cog.create_channels = AsyncMock() +        self.cog.add_roles = AsyncMock() + +        member = MockMember() +        await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) + +        self.ctx.send.assert_awaited_once() +        self.cog.create_channels.assert_not_awaited() +        self.cog.add_roles.assert_not_awaited() + +    async def test_result_sending(self): +        """Should call `ctx.send` when everything goes right.""" +        self.cog.create_channels = AsyncMock() +        self.cog.add_roles = AsyncMock() + +        members = [MockMember() for _ in range(5)] +        await self.cog.createteam(self.cog, self.ctx, "foo", members) + +        self.cog.create_channels.assert_awaited_once() +        self.cog.add_roles.assert_awaited_once() +        self.ctx.send.assert_awaited_once() + +    async def test_category_doesnt_exist(self): +        """Should create a new code jam category.""" +        subtests = ( +            [], +            [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)], +            [get_mock_category(jams.MAX_CHANNELS - 2, "other")], +        ) + +        for categories in subtests: +            self.guild.reset_mock() +            self.guild.categories = categories + +            with self.subTest(categories=categories): +                actual_category = await self.cog.get_category(self.guild) + +                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(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME) +        self.guild.categories = [ +            get_mock_category(jams.MAX_CHANNELS - 2, "other"), +            expected_category, +            get_mock_category(0, jams.CATEGORY_NAME), +        ] + +        actual_category = await self.cog.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() +        members = [leader] + [MockMember() for _ in range(4)] +        overwrites = self.cog.get_overwrites(members, self.guild) + +        # Leader permission overwrites +        self.assertTrue(overwrites[leader].manage_messages) +        self.assertTrue(overwrites[leader].read_messages) +        self.assertTrue(overwrites[leader].manage_webhooks) +        self.assertTrue(overwrites[leader].connect) + +        # Other members permission overwrites +        for member in members[1:]: +            self.assertTrue(overwrites[member].read_messages) +            self.assertTrue(overwrites[member].connect) + +        # Everyone and verified role overwrite +        self.assertFalse(overwrites[self.guild.default_role].read_messages) +        self.assertFalse(overwrites[self.guild.default_role].connect) +        self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].read_messages) +        self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].connect) + +    async def test_team_channels_creation(self): +        """Should create new voice and text channel for team.""" +        members = [MockMember() for _ in range(5)] + +        self.cog.get_overwrites = MagicMock() +        self.cog.get_category = AsyncMock() +        self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel") +        actual = await self.cog.create_channels(self.guild, "my-team", members) + +        self.assertEqual("foobar-channel", actual) +        self.cog.get_overwrites.assert_called_once_with(members, self.guild) +        self.cog.get_category.assert_awaited_once_with(self.guild) + +        self.guild.create_text_channel.assert_awaited_once_with( +            "my-team", +            overwrites=self.cog.get_overwrites.return_value, +            category=self.cog.get_category.return_value +        ) +        self.guild.create_voice_channel.assert_awaited_once_with( +            "My Team", +            overwrites=self.cog.get_overwrites.return_value, +            category=self.cog.get_category.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") +        jam_role = MockRole(name="Jammer") +        self.guild.get_role.side_effect = [leader_role, jam_role] + +        leader = MockMember() +        members = [leader] + [MockMember() for _ in range(4)] +        await self.cog.add_roles(self.guild, members) + +        leader.add_roles.assert_any_await(leader_role) +        for member in members: +            member.add_roles.assert_any_await(jam_role) + + +class CodeJamSetup(unittest.TestCase): +    """Test for `setup` function of `CodeJam` cog.""" + +    def test_setup(self): +        """Should call `bot.add_cog`.""" +        bot = MockBot() +        jams.setup(bot) +        bot.add_cog.assert_called_once() | 
