diff options
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | bot/cogs/sync/cog.py | 55 | ||||
| -rw-r--r-- | bot/cogs/utils.py | 14 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/bigbrother.py | 2 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/talentpool.py | 2 | ||||
| -rw-r--r-- | bot/constants.py | 6 | ||||
| -rw-r--r-- | bot/rules/attachments.py | 4 | ||||
| -rw-r--r-- | config-default.yml | 21 | ||||
| -rw-r--r-- | tests/bot/rules/test_attachments.py | 110 | ||||
| -rw-r--r-- | tests/bot/rules/test_links.py | 26 | ||||
| -rw-r--r-- | tests/bot/rules/test_mentions.py | 95 | 
11 files changed, 246 insertions, 91 deletions
| @@ -1,6 +1,6 @@  # Python Utility Bot -[](https://discord.gg/2B963hn) +[](https://discord.gg/2B963hn)  [](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=1&branchName=master)  [](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)  [](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 90d4c40fe..4e6ed156b 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,7 +1,7 @@  import logging -from typing import Callable, Iterable +from typing import Callable, Dict, Iterable, Union -from discord import Guild, Member, Role +from discord import Guild, Member, Role, User  from discord.ext import commands  from discord.ext.commands import Cog, Context @@ -51,6 +51,15 @@ class Sync(Cog):                          f"deleted `{total_deleted}`."                      ) +    async def patch_user(self, user_id: int, updated_information: Dict[str, Union[str, int]]) -> None: +        """Send a PATCH request to partially update a user in the database.""" +        try: +            await self.bot.api_client.patch("bot/users/" + str(user_id), json=updated_information) +        except ResponseCodeError as e: +            if e.response.status != 404: +                raise +            log.warning("Unable to update user, got 404. Assuming race condition from join event.") +      @Cog.listener()      async def on_guild_role_create(self, role: Role) -> None:          """Adds newly create role to the database table over the API.""" @@ -143,33 +152,21 @@ class Sync(Cog):      @Cog.listener()      async def on_member_update(self, before: Member, after: Member) -> None: -        """Updates the user information if any of relevant attributes have changed.""" -        if ( -                before.name != after.name -                or before.avatar != after.avatar -                or before.discriminator != after.discriminator -                or before.roles != after.roles -        ): -            try: -                await self.bot.api_client.put( -                    'bot/users/' + str(after.id), -                    json={ -                        'avatar_hash': after.avatar, -                        'discriminator': int(after.discriminator), -                        'id': after.id, -                        'in_guild': True, -                        'name': after.name, -                        'roles': sorted(role.id for role in after.roles) -                    } -                ) -            except ResponseCodeError as e: -                if e.response.status != 404: -                    raise - -                log.warning( -                    "Unable to update user, got 404. " -                    "Assuming race condition from join event." -                ) +        """Update the roles of the member in the database if a change is detected.""" +        if before.roles != after.roles: +            updated_information = {"roles": sorted(role.id for role in after.roles)} +            await self.patch_user(after.id, updated_information=updated_information) + +    @Cog.listener() +    async def on_user_update(self, before: User, after: User) -> None: +        """Update the user information in the database if a relevant change is detected.""" +        if any(getattr(before, attr) != getattr(after, attr) for attr in ("name", "discriminator", "avatar")): +            updated_information = { +                "name": after.name, +                "discriminator": int(after.discriminator), +                "avatar_hash": after.avatar, +            } +            await self.patch_user(after.id, updated_information=updated_information)      @commands.group(name='sync')      @commands.has_permissions(administrator=True) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 47a59db66..da278011a 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -62,14 +62,12 @@ class Utils(Cog):                  pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png")                  # Add the interesting information -                if "Status" in pep_header: -                    pep_embed.add_field(name="Status", value=pep_header["Status"]) -                if "Python-Version" in pep_header: -                    pep_embed.add_field(name="Python-Version", value=pep_header["Python-Version"]) -                if "Created" in pep_header: -                    pep_embed.add_field(name="Created", value=pep_header["Created"]) -                if "Type" in pep_header: -                    pep_embed.add_field(name="Type", value=pep_header["Type"]) +                fields_to_check = ("Status", "Python-Version", "Created", "Type") +                for field in fields_to_check: +                    # Check for a PEP metadata field that is present but has an empty value +                    # embed field values can't contain an empty string +                    if pep_header.get(field, ""): +                        pep_embed.add_field(name=field, value=pep_header[field])              elif response.status != 404:                  # any response except 200 and 404 is expected diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index fbee4f5d7..28c1681cf 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -62,7 +62,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):              return          if user.id in self.watched_users: -            await ctx.send(":x: The specified user is already being watched.") +            await ctx.send(f":x: {user} is already being watched.")              return          response = await post_infraction(ctx, user, 'watch', reason, hidden=True, active=True) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index cc8feeeee..f990ccff8 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -69,7 +69,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):              return          if user.id in self.watched_users: -            await ctx.send(":x: The specified user is already being watched in the talent pool") +            await ctx.send(f":x: {user} is already being watched in the talent pool")              return          # Manual request with `raise_for_status` as False because we want the actual response diff --git a/bot/constants.py b/bot/constants.py index 8815ab983..2c0e3b10b 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -270,6 +270,12 @@ class Emojis(metaclass=YAMLGetter):      ducky_ninja: int      ducky_devil: int      ducky_tube: int +    ducky_hunt: int +    ducky_wizard: int +    ducky_party: int +    ducky_angel: int +    ducky_maul: int +    ducky_santa: int      upvotes: str      comments: str diff --git a/bot/rules/attachments.py b/bot/rules/attachments.py index c550aed76..00bb2a949 100644 --- a/bot/rules/attachments.py +++ b/bot/rules/attachments.py @@ -7,14 +7,14 @@ async def apply(      last_message: Message, recent_messages: List[Message], config: Dict[str, int]  ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:      """Detects total attachments exceeding the limit sent by a single user.""" -    relevant_messages = [last_message] + [ +    relevant_messages = tuple(          msg          for msg in recent_messages          if (              msg.author == last_message.author              and len(msg.attachments) > 0          ) -    ] +    )      total_recent_attachments = sum(len(msg.attachments) for msg in relevant_messages)      if total_recent_attachments > config['max']: diff --git a/config-default.yml b/config-default.yml index 6ae07da93..bf8509544 100644 --- a/config-default.yml +++ b/config-default.yml @@ -41,6 +41,12 @@ style:          ducky_ninja:    &DUCKY_NINJA    637923502535606293          ducky_devil:    &DUCKY_DEVIL    637925314982576139          ducky_tube:     &DUCKY_TUBE     637881368008851456 +        ducky_hunt:     &DUCKY_HUNT     639355090909528084 +        ducky_wizard:   &DUCKY_WIZARD   639355996954689536 +        ducky_party:    &DUCKY_PARTY    639468753440210977 +        ducky_angel:    &DUCKY_ANGEL    640121935610511361 +        ducky_maul:     &DUCKY_MAUL     640137724958867467 +        ducky_santa:    &DUCKY_SANTA    655360331002019870          upvotes:        "<:upvotes:638729835245731840>"          comments:       "<:comments:638729835073765387>" @@ -148,7 +154,7 @@ guild:          contributor:                        295488872404484098          core_developer:                     587606783669829632          helpers:                            267630620367257601 -        jammer:                             423054537079783434 +        jammer:                             591786436651646989          moderator:         &MOD_ROLE        267629731250176001          muted:             &MUTED_ROLE      277914926603829249          owner:             &OWNER_ROLE      267627879762755584 @@ -195,6 +201,9 @@ filter:          - 544525886180032552  # kennethreitz.org          - 590806733924859943  # Discord Hack Week          - 423249981340778496  # Kivy +        - 197038439483310086  # Discord Testers +        - 286633898581164032  # Ren'Py +        - 349505959032389632  # PyGame      domain_blacklist:          - pornhub.com @@ -362,6 +371,14 @@ anti_malware:          - '.png'          - '.tiff'          - '.wmv' +        - '.svg' +        - '.psd'  # Photoshop +        - '.ai'   # Illustrator +        - '.aep'  # After Effects +        - '.xcf'  # GIMP +        - '.mp3' +        - '.wav' +        - '.ogg'  reddit: @@ -400,7 +417,7 @@ redirect_output:  duck_pond:      threshold: 5 -    custom_emojis: [*DUCKY_YELLOW, *DUCKY_BLURPLE, *DUCKY_CAMO, *DUCKY_DEVIL, *DUCKY_NINJA, *DUCKY_REGAL, *DUCKY_TUBE] +    custom_emojis: [*DUCKY_YELLOW, *DUCKY_BLURPLE, *DUCKY_CAMO, *DUCKY_DEVIL, *DUCKY_NINJA, *DUCKY_REGAL, *DUCKY_TUBE, *DUCKY_HUNT, *DUCKY_WIZARD, *DUCKY_PARTY, *DUCKY_ANGEL, *DUCKY_MAUL, *DUCKY_SANTA]  config:      required_keys: ['bot.token'] diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index 4bb0acf7c..d7187f315 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -1,52 +1,98 @@ -import asyncio  import unittest -from dataclasses import dataclass -from typing import Any, List +from typing import List, NamedTuple, Tuple  from bot.rules import attachments +from tests.helpers import MockMessage, async_test -# Using `MagicMock` sadly doesn't work for this usecase -# since it's __eq__ compares the MagicMock's ID. We just -# want to compare the actual attributes we set. -@dataclass -class FakeMessage: -    author: str -    attachments: List[Any] +class Case(NamedTuple): +    recent_messages: List[MockMessage] +    culprit: Tuple[str] +    total_attachments: int -def msg(total_attachments: int) -> FakeMessage: -    return FakeMessage(author='lemon', attachments=list(range(total_attachments))) +def msg(author: str, total_attachments: int) -> MockMessage: +    """Builds a message with `total_attachments` attachments.""" +    return MockMessage(author=author, attachments=list(range(total_attachments)))  class AttachmentRuleTests(unittest.TestCase): -    """Tests applying the `attachment` antispam rule.""" +    """Tests applying the `attachments` antispam rule.""" -    def test_allows_messages_without_too_many_attachments(self): +    def setUp(self): +        self.config = {"max": 5} + +    @async_test +    async def test_allows_messages_without_too_many_attachments(self):          """Messages without too many attachments are allowed as-is."""          cases = ( -            (msg(0), msg(0), msg(0)), -            (msg(2), msg(2)), -            (msg(0),), +            [msg("bob", 0), msg("bob", 0), msg("bob", 0)], +            [msg("bob", 2), msg("bob", 2)], +            [msg("bob", 2), msg("alice", 2), msg("bob", 2)],          ) -        for last_message, *recent_messages in cases: -            with self.subTest(last_message=last_message, recent_messages=recent_messages): -                coro = attachments.apply(last_message, recent_messages, {'max': 5}) -                self.assertIsNone(asyncio.run(coro)) +        for recent_messages in cases: +            last_message = recent_messages[0] + +            with self.subTest( +                last_message=last_message, +                recent_messages=recent_messages, +                config=self.config +            ): +                self.assertIsNone( +                    await attachments.apply(last_message, recent_messages, self.config) +                ) -    def test_disallows_messages_with_too_many_attachments(self): +    @async_test +    async def test_disallows_messages_with_too_many_attachments(self):          """Messages with too many attachments trigger the rule."""          cases = ( -            ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), -            ((msg(6),), [msg(6)], 6), -            ((msg(1),) * 6, [msg(1)] * 6, 6), +            Case( +                [msg("bob", 4), msg("bob", 0), msg("bob", 6)], +                ("bob",), +                10 +            ), +            Case( +                [msg("bob", 4), msg("alice", 6), msg("bob", 2)], +                ("bob",), +                6 +            ), +            Case( +                [msg("alice", 6)], +                ("alice",), +                6 +            ), +            ( +                [msg("alice", 1) for _ in range(6)], +                ("alice",), +                6 +            ),          ) -        for messages, relevant_messages, total in cases: -            with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total): -                last_message, *recent_messages = messages -                coro = attachments.apply(last_message, recent_messages, {'max': 5}) -                self.assertEqual( -                    asyncio.run(coro), -                    (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) + +        for recent_messages, culprit, total_attachments in cases: +            last_message = recent_messages[0] +            relevant_messages = tuple( +                msg +                for msg in recent_messages +                if ( +                    msg.author == last_message.author +                    and len(msg.attachments) > 0 +                ) +            ) + +            with self.subTest( +                last_message=last_message, +                recent_messages=recent_messages, +                relevant_messages=relevant_messages, +                total_attachments=total_attachments, +                config=self.config +            ): +                desired_output = ( +                    f"sent {total_attachments} attachments in {self.config['max']}s", +                    culprit, +                    relevant_messages +                ) +                self.assertTupleEqual( +                    await attachments.apply(last_message, recent_messages, self.config), +                    desired_output                  ) diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index be832843b..02a5d5501 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -2,25 +2,19 @@ import unittest  from typing import List, NamedTuple, Tuple  from bot.rules import links -from tests.helpers import async_test - - -class FakeMessage(NamedTuple): -    author: str -    content: str +from tests.helpers import MockMessage, async_test  class Case(NamedTuple): -    recent_messages: List[FakeMessage] -    relevant_messages: Tuple[FakeMessage] +    recent_messages: List[MockMessage]      culprit: Tuple[str]      total_links: int -def msg(author: str, total_links: int) -> FakeMessage: -    """Makes a message with *total_links* links.""" +def msg(author: str, total_links: int) -> MockMessage: +    """Makes a message with `total_links` links."""      content = " ".join(["https://pydis.com"] * total_links) -    return FakeMessage(author=author, content=content) +    return MockMessage(author=author, content=content)  class LinksTests(unittest.TestCase): @@ -61,26 +55,28 @@ class LinksTests(unittest.TestCase):          cases = (              Case(                  [msg("bob", 1), msg("bob", 2)], -                (msg("bob", 1), msg("bob", 2)),                  ("bob",),                  3              ),              Case(                  [msg("alice", 1), msg("alice", 1), msg("alice", 1)], -                (msg("alice", 1), msg("alice", 1), msg("alice", 1)),                  ("alice",),                  3              ),              Case(                  [msg("alice", 2), msg("bob", 3), msg("alice", 1)], -                (msg("alice", 2), msg("alice", 1)),                  ("alice",),                  3              )          ) -        for recent_messages, relevant_messages, culprit, total_links in cases: +        for recent_messages, culprit, total_links in cases:              last_message = recent_messages[0] +            relevant_messages = tuple( +                msg +                for msg in recent_messages +                if msg.author == last_message.author +            )              with self.subTest(                  last_message=last_message, diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py new file mode 100644 index 000000000..ad49ead32 --- /dev/null +++ b/tests/bot/rules/test_mentions.py @@ -0,0 +1,95 @@ +import unittest +from typing import List, NamedTuple, Tuple + +from bot.rules import mentions +from tests.helpers import MockMessage, async_test + + +class Case(NamedTuple): +    recent_messages: List[MockMessage] +    culprit: Tuple[str] +    total_mentions: int + + +def msg(author: str, total_mentions: int) -> MockMessage: +    """Makes a message with `total_mentions` mentions.""" +    return MockMessage(author=author, mentions=list(range(total_mentions))) + + +class TestMentions(unittest.TestCase): +    """Tests applying the `mentions` antispam rule.""" + +    def setUp(self): +        self.config = { +            "max": 2, +            "interval": 10 +        } + +    @async_test +    async def test_mentions_within_limit(self): +        """Messages with an allowed amount of mentions.""" +        cases = ( +            [msg("bob", 0)], +            [msg("bob", 2)], +            [msg("bob", 1), msg("bob", 1)], +            [msg("bob", 1), msg("alice", 2)] +        ) + +        for recent_messages in cases: +            last_message = recent_messages[0] + +            with self.subTest( +                last_message=last_message, +                recent_messages=recent_messages, +                config=self.config +            ): +                self.assertIsNone( +                    await mentions.apply(last_message, recent_messages, self.config) +                ) + +    @async_test +    async def test_mentions_exceeding_limit(self): +        """Messages with a higher than allowed amount of mentions.""" +        cases = ( +            Case( +                [msg("bob", 3)], +                ("bob",), +                3 +            ), +            Case( +                [msg("alice", 2), msg("alice", 0), msg("alice", 1)], +                ("alice",), +                3 +            ), +            Case( +                [msg("bob", 2), msg("alice", 3), msg("bob", 2)], +                ("bob",), +                4 +            ) +        ) + +        for recent_messages, culprit, total_mentions in cases: +            last_message = recent_messages[0] +            relevant_messages = tuple( +                msg +                for msg in recent_messages +                if msg.author == last_message.author +            ) + +            with self.subTest( +                last_message=last_message, +                recent_messages=recent_messages, +                relevant_messages=relevant_messages, +                culprit=culprit, +                total_mentions=total_mentions, +                cofig=self.config +            ): +                desired_output = ( +                    f"sent {total_mentions} mentions in {self.config['interval']}s", +                    culprit, +                    relevant_messages +                ) +                self.assertTupleEqual( +                    await mentions.apply(last_message, recent_messages, self.config), +                    desired_output +                ) | 
