From 409f0b5e5a0f71a0858c91ffde46275cf755f067 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 11:41:48 +0200 Subject: Determine eligible duckpond emojis dynamically Instead of maintaining a list of duckpond IDs manually, it's a much better idea to detect ducky emojis dynamically using the new emoji name grouping we've introduced: All emojis that start with `ducky_` will now be counted as a duckpond ducky. The unicode duck emoji obviously still counts in addition to custom emojis with the `ducky_` prefix. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/duck_pond.py | 54 +++++++++++++++++++++++++-------------------------- bot/constants.py | 15 -------------- config-default.yml | 28 -------------------------- 3 files changed, 27 insertions(+), 70 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 7021069fa..e1aceb482 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -49,32 +49,32 @@ class DuckPond(Cog): return True return False + @staticmethod + def _is_duck_emoji(emoji: Union[str, discord.PartialEmoji, discord.Emoji]) -> bool: + """Check if the emoji is a valid duck emoji.""" + if isinstance(emoji, str): + return emoji == "🦆" + else: + return hasattr(emoji, "name") and emoji.name.startswith("ducky_") + async def count_ducks(self, message: Message) -> int: """ Count the number of ducks in the reactions of a specific message. Only counts ducks added by staff members. """ - duck_count = 0 - duck_reactors = [] + duck_reactors = set() + # iterate over all reactions for reaction in message.reactions: - async for user in reaction.users(): - - # Is the user a staff member and not already counted as reactor? - if not self.is_staff(user) or user.id in duck_reactors: - continue - - # Is the emoji a duck? - if hasattr(reaction.emoji, "id"): - if reaction.emoji.id in constants.DuckPond.custom_emojis: - duck_count += 1 - duck_reactors.append(user.id) - elif isinstance(reaction.emoji, str): - if reaction.emoji == "🦆": - duck_count += 1 - duck_reactors.append(user.id) - return duck_count + # check if the current reaction is a duck + if not self._is_duck_emoji(reaction.emoji): + continue + + # update the set of reactors with all staff reactors + duck_reactors |= {user.id async for user in reaction.users() if self.is_staff(user)} + + return len(duck_reactors) async def relay_message(self, message: Message) -> None: """Relays the message's content and attachments to the duck pond channel.""" @@ -105,16 +105,16 @@ class DuckPond(Cog): await message.add_reaction("✅") - @staticmethod - def _payload_has_duckpond_emoji(payload: RawReactionActionEvent) -> bool: + def _payload_has_duckpond_emoji(self, emoji: discord.PartialEmoji) -> bool: """Test if the RawReactionActionEvent payload contains a duckpond emoji.""" - if payload.emoji.is_custom_emoji(): - if payload.emoji.id in constants.DuckPond.custom_emojis: - return True - elif payload.emoji.name == "🦆": - return True + if emoji.is_unicode_emoji(): + # For unicode PartialEmojis, the `name` attribute is just the string + # representation of the emoji. This is what the helper method + # expects, as unicode emojis show up as just a `str` instance when + # inspecting the reactions attached to a message. + emoji = emoji.name - return False + return self._is_duck_emoji(emoji) @Cog.listener() async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: @@ -126,7 +126,7 @@ class DuckPond(Cog): send the message off to the duck pond. """ # Is the emoji in the reaction a duck? - if not self._payload_has_duckpond_emoji(payload): + if not self._payload_has_duckpond_emoji(payload.emoji): return channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) diff --git a/bot/constants.py b/bot/constants.py index 17f14fec0..f087fd96f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -252,7 +252,6 @@ class DuckPond(metaclass=YAMLGetter): section = "duck_pond" threshold: int - custom_emojis: List[int] class Emojis(metaclass=YAMLGetter): @@ -292,20 +291,6 @@ class Emojis(metaclass=YAMLGetter): cross_mark: str check_mark: str - ducky_yellow: int - ducky_blurple: int - ducky_regal: int - ducky_camo: int - 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 user: str diff --git a/config-default.yml b/config-default.yml index 58651f548..8d13b2d11 100644 --- a/config-default.yml +++ b/config-default.yml @@ -62,20 +62,6 @@ style: cross_mark: "\u274C" check_mark: "\u2705" - ducky_yellow: &DUCKY_YELLOW 574951975574175744 - ducky_blurple: &DUCKY_BLURPLE 574951975310065675 - ducky_regal: &DUCKY_REGAL 637883439185395712 - ducky_camo: &DUCKY_CAMO 637914731566596096 - 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 - # emotes used for #reddit upvotes: "<:reddit_upvotes:755845219890757644>" comments: "<:reddit_comments:755845255001014384>" @@ -468,20 +454,6 @@ sync: duck_pond: threshold: 5 - 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 python_news: mail_lists: -- cgit v1.2.3 From 3fea32523d84ee5e2ba0e68710191ffb61220d58 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 12:03:04 +0200 Subject: Ignore non-staff messages for our duckpond Some of our members have expressed concern that their messages would be "ducked" by staff members and relayed to the staff-only duckpond. Since duckpond is supposed to be a funny, staff-only affair, I've made duckpond ignore messages from non-staff members. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/duck_pond.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index e1aceb482..66e862ab2 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -133,7 +133,11 @@ class DuckPond(Cog): message = await channel.fetch_message(payload.message_id) member = discord.utils.get(message.guild.members, id=payload.user_id) - # Is the member a human and a staff member? + # Was the message sent by a human staff member? + if not self.is_staff(message.author) or message.author.bot: + return + + # Is the reactor a human staff member? if not self.is_staff(member) or member.bot: return -- cgit v1.2.3 From 9410194c058675428c4ed99f403e84cb65c18a71 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 12:04:56 +0200 Subject: Add channel blacklist for duckpond As announcements already get a lot of exposure and have a high risk of getting "ducked", duckpond will now ignore those channels and never relay those announcements to our duckpond. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/duck_pond.py | 4 ++++ bot/constants.py | 8 ++++++++ config-default.yml | 30 ++++++++++++++++++++++++++---- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 66e862ab2..84c0c4265 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -125,6 +125,10 @@ class DuckPond(Cog): amount of ducks specified in the config under duck_pond/threshold, it will send the message off to the duck pond. """ + # Was this reaction issued in a blacklisted channel? + if payload.channel_id in constants.DuckPond.channel_blacklist: + return + # Is the emoji in the reaction a duck? if not self._payload_has_duckpond_emoji(payload.emoji): return diff --git a/bot/constants.py b/bot/constants.py index f087fd96f..aec0ffce3 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -252,6 +252,7 @@ class DuckPond(metaclass=YAMLGetter): section = "duck_pond" threshold: int + channel_blacklist: List[int] class Emojis(metaclass=YAMLGetter): @@ -380,12 +381,14 @@ class Channels(metaclass=YAMLGetter): section = "guild" subsection = "channels" + admin_announcements: int admin_spam: int admins: int announcements: int attachment_log: int big_brother_logs: int bot_commands: int + change_log: int cooldown: int defcon: int dev_contrib: int @@ -397,9 +400,11 @@ class Channels(metaclass=YAMLGetter): how_to_get_help: int incidents: int incidents_archive: int + mailing_lists: int message_log: int meta: int mod_alerts: int + mod_announcements: int mod_log: int mod_spam: int mods: int @@ -408,7 +413,10 @@ class Channels(metaclass=YAMLGetter): off_topic_2: int organisation: int python_discussion: int + python_events: int + python_news: int reddit: int + staff_announcements: int talent_pool: int user_event_announcements: int user_log: int diff --git a/config-default.yml b/config-default.yml index 8d13b2d11..4c1c7e483 100644 --- a/config-default.yml +++ b/config-default.yml @@ -130,9 +130,14 @@ guild: modmail: 714494672835444826 channels: - announcements: 354619224620138496 - user_event_announcements: &USER_EVENT_A 592000283102674944 - python_news: &PYNEWS_CHANNEL 704372456592506880 + # Public announcement and news channels + change_log: &CHANGE_LOG 748238795236704388 + announcements: &ANNOUNCEMENTS 354619224620138496 + python_news: &PYNEWS_CHANNEL 704372456592506880 + python_events: &PYEVENTS_CHANNEL 729674110270963822 + mailing_lists: &MAILING_LISTS 704372456592506880 + reddit: &REDDIT_CHANNEL 458224812528238616 + user_event_announcements: &USER_EVENT_A 592000283102674944 # Development dev_contrib: &DEV_CONTRIB 635950537262759947 @@ -163,7 +168,6 @@ guild: # Special bot_commands: &BOT_CMD 267659945086812160 esoteric: 470884583684964352 - reddit: 458224812528238616 verification: 352442727016693763 # Staff @@ -178,6 +182,12 @@ guild: mod_spam: &MOD_SPAM 620607373828030464 organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 + duck_pond: &DUCK_POND 637820308341915648 + + # Staff announcement channels + staff_announcements: &STAFF_ANNOUNCEMENTS 464033278631084042 + mod_announcements: &MOD_ANNOUNCEMENTS 372115205867700225 + admin_announcements: &ADMIN_ANNOUNCEMENTS 749736155569848370 # Voice admins_voice: &ADMINS_VOICE 500734494840717332 @@ -454,6 +464,18 @@ sync: duck_pond: threshold: 5 + channel_blacklist: + - *ANNOUNCEMENTS + - *PYNEWS_CHANNEL + - *PYEVENTS_CHANNEL + - *MAILING_LISTS + - *REDDIT_CHANNEL + - *USER_EVENT_A + - *DUCK_POND + - *CHANGE_LOG + - *STAFF_ANNOUNCEMENTS + - *MOD_ANNOUNCEMENTS + - *ADMIN_ANNOUNCEMENTS python_news: mail_lists: -- cgit v1.2.3 From d68d6d2858b8df74c48c00c0af23de24aa5022dc Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 12:17:30 +0200 Subject: Fix relay race condition in duckpond using a lock Our duckpond suffered from a race condition: If multiple raw reaction events were received in quick succession and a message had enough ducks to take it over the duckpond threshold, the message would be relayed multiple times. The reason this happened is because the green checkmark emoji that stops a message from being relayed multiple times is only added after the message has been relayed. This means that multiple event triggers can make it past the green checkmark check before any of them has a chance to add a green checkmark. The solution was to create a relay lock that needs to be acquired before checking for the presence of a green checkmark and is only released after adding a green checkmark. This prevents multiple events from making it past the sentinel check. As our Cogs are potentially initialized before the event loop is created, the lock is load lazily when needed. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/duck_pond.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 84c0c4265..12f4cb7b8 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -1,3 +1,4 @@ +import asyncio import logging from typing import Union @@ -21,6 +22,7 @@ class DuckPond(Cog): self.webhook_id = constants.Webhooks.duck_pond self.webhook = None self.bot.loop.create_task(self.fetch_webhook()) + self.relay_lock = None async def fetch_webhook(self) -> None: """Fetches the webhook object, so we can post to it.""" @@ -103,8 +105,6 @@ class DuckPond(Cog): except discord.HTTPException: log.exception("Failed to send an attachment to the webhook") - await message.add_reaction("✅") - def _payload_has_duckpond_emoji(self, emoji: discord.PartialEmoji) -> bool: """Test if the RawReactionActionEvent payload contains a duckpond emoji.""" if emoji.is_unicode_emoji(): @@ -145,16 +145,26 @@ class DuckPond(Cog): if not self.is_staff(member) or member.bot: return - # Does the message already have a green checkmark? - if await self.has_green_checkmark(message): - return - # Time to count our ducks! duck_count = await self.count_ducks(message) # If we've got more than the required amount of ducks, send the message to the duck_pond. if duck_count >= constants.DuckPond.threshold: - await self.relay_message(message) + if self.relay_lock is None: + # Lazily load the lock to ensure it's created within the + # appropriate event loop. + self.relay_lock = asyncio.Lock() + + async with self.relay_lock: + # check if the message has a checkmark after acquiring the lock + if await self.has_green_checkmark(message): + return + + # relay the message + await self.relay_message(message) + + # add a green checkmark to indicate that the message was relayed + await message.add_reaction("✅") @Cog.listener() async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None: -- cgit v1.2.3 From 0c5c472362d2891853bb82b225aa86da74d597e2 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 12:43:00 +0200 Subject: Remove unit tests for duck pond I've removed the unit tests for duckpond in concordance with the new policy for writing unit tests for the bot The tests were unnecessarily complicated, difficult to maintain, and slowed development. Signed-off-by: Sebastiaan Zeeff --- tests/bot/cogs/test_duck_pond.py | 548 --------------------------------------- 1 file changed, 548 deletions(-) delete mode 100644 tests/bot/cogs/test_duck_pond.py diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py deleted file mode 100644 index cfe10aebf..000000000 --- a/tests/bot/cogs/test_duck_pond.py +++ /dev/null @@ -1,548 +0,0 @@ -import asyncio -import logging -import typing -import unittest -from unittest.mock import AsyncMock, MagicMock, patch - -import discord - -from bot import constants -from bot.cogs import duck_pond -from tests import base -from tests import helpers - -MODULE_PATH = "bot.cogs.duck_pond" - - -class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): - """Tests for DuckPond functionality.""" - - @classmethod - def setUpClass(cls): - """Sets up the objects that only have to be initialized once.""" - cls.nonstaff_member = helpers.MockMember(name="Non-staffer") - - cls.staff_role = helpers.MockRole(name="Staff role", id=constants.STAFF_ROLES[0]) - cls.staff_member = helpers.MockMember(name="staffer", roles=[cls.staff_role]) - - cls.checkmark_emoji = "\N{White Heavy Check Mark}" - cls.thumbs_up_emoji = "\N{Thumbs Up Sign}" - cls.unicode_duck_emoji = "\N{Duck}" - cls.duck_pond_emoji = helpers.MockPartialEmoji(id=constants.DuckPond.custom_emojis[0]) - cls.non_duck_custom_emoji = helpers.MockPartialEmoji(id=123) - - def setUp(self): - """Sets up the objects that need to be refreshed before each test.""" - self.bot = helpers.MockBot(user=helpers.MockMember(id=46692)) - self.cog = duck_pond.DuckPond(bot=self.bot) - - def test_duck_pond_correctly_initializes(self): - """`__init__ should set `bot` and `webhook_id` attributes and schedule `fetch_webhook`.""" - bot = helpers.MockBot() - cog = MagicMock() - - duck_pond.DuckPond.__init__(cog, bot) - - self.assertEqual(cog.bot, bot) - self.assertEqual(cog.webhook_id, constants.Webhooks.duck_pond) - bot.loop.create_task.assert_called_once_with(cog.fetch_webhook()) - - def test_fetch_webhook_succeeds_without_connectivity_issues(self): - """The `fetch_webhook` method waits until `READY` event and sets the `webhook` attribute.""" - self.bot.fetch_webhook.return_value = "dummy webhook" - self.cog.webhook_id = 1 - - asyncio.run(self.cog.fetch_webhook()) - - self.bot.wait_until_guild_available.assert_called_once() - self.bot.fetch_webhook.assert_called_once_with(1) - self.assertEqual(self.cog.webhook, "dummy webhook") - - def test_fetch_webhook_logs_when_unable_to_fetch_webhook(self): - """The `fetch_webhook` method should log an exception when it fails to fetch the webhook.""" - self.bot.fetch_webhook.side_effect = discord.HTTPException(response=MagicMock(), message="Not found.") - self.cog.webhook_id = 1 - - log = logging.getLogger('bot.cogs.duck_pond') - with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - asyncio.run(self.cog.fetch_webhook()) - - self.bot.wait_until_guild_available.assert_called_once() - self.bot.fetch_webhook.assert_called_once_with(1) - - self.assertEqual(len(log_watcher.records), 1) - - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.ERROR) - - def test_is_staff_returns_correct_values_based_on_instance_passed(self): - """The `is_staff` method should return correct values based on the instance passed.""" - test_cases = ( - (helpers.MockUser(name="User instance"), False), - (helpers.MockMember(name="Member instance without staff role"), False), - (helpers.MockMember(name="Member instance with staff role", roles=[self.staff_role]), True) - ) - - for user, expected_return in test_cases: - actual_return = self.cog.is_staff(user) - with self.subTest(user_type=user.name, expected_return=expected_return, actual_return=actual_return): - self.assertEqual(expected_return, actual_return) - - async def test_has_green_checkmark_correctly_detects_presence_of_green_checkmark_emoji(self): - """The `has_green_checkmark` method should only return `True` if one is present.""" - test_cases = ( - ( - "No reactions", helpers.MockMessage(), False - ), - ( - "No green check mark reactions", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.thumbs_up_emoji, users=[self.bot.user]) - ]), - False - ), - ( - "Green check mark reaction, but not from our bot", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member]) - ]), - False - ), - ( - "Green check mark reaction, with one from the bot", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user]) - ]), - True - ) - ) - - for description, message, expected_return in test_cases: - actual_return = await self.cog.has_green_checkmark(message) - with self.subTest( - test_case=description, - expected_return=expected_return, - actual_return=actual_return - ): - self.assertEqual(expected_return, actual_return) - - def _get_reaction( - self, - emoji: typing.Union[str, helpers.MockEmoji], - staff: int = 0, - nonstaff: int = 0 - ) -> helpers.MockReaction: - staffers = [helpers.MockMember(roles=[self.staff_role]) for _ in range(staff)] - nonstaffers = [helpers.MockMember() for _ in range(nonstaff)] - return helpers.MockReaction(emoji=emoji, users=staffers + nonstaffers) - - async def test_count_ducks_correctly_counts_the_number_of_eligible_duck_emojis(self): - """The `count_ducks` method should return the number of unique staffers who gave a duck.""" - test_cases = ( - # Simple test cases - # A message without reactions should return 0 - ( - "No reactions", - helpers.MockMessage(), - 0 - ), - # A message with a non-duck reaction from a non-staffer should return 0 - ( - "Non-duck reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, nonstaff=1)]), - 0 - ), - # A message with a non-duck reaction from a staffer should return 0 - ( - "Non-duck reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.non_duck_custom_emoji, staff=1)]), - 0 - ), - # A message with a non-duck reaction from a non-staffer and staffer should return 0 - ( - "Non-duck reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, staff=1, nonstaff=1)]), - 0 - ), - # A message with a unicode duck reaction from a non-staffer should return 0 - ( - "Unicode Duck Reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, nonstaff=1)]), - 0 - ), - # A message with a unicode duck reaction from a staffer should return 1 - ( - "Unicode Duck Reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1)]), - 1 - ), - # A message with a unicode duck reaction from a non-staffer and staffer should return 1 - ( - "Unicode Duck Reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1, nonstaff=1)]), - 1 - ), - # A message with a duckpond duck reaction from a non-staffer should return 0 - ( - "Duckpond Duck Reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, nonstaff=1)]), - 0 - ), - # A message with a duckpond duck reaction from a staffer should return 1 - ( - "Duckpond Duck Reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1)]), - 1 - ), - # A message with a duckpond duck reaction from a non-staffer and staffer should return 1 - ( - "Duckpond Duck Reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1, nonstaff=1)]), - 1 - ), - - # Complex test cases - # A message with duckpond duck reactions from 3 staffers and 2 non-staffers returns 3 - ( - "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2)]), - 3 - ), - # A staffer with multiple duck reactions only counts once - ( - "Two different duck reactions from the same staffer", - helpers.MockMessage( - reactions=[ - helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]), - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]), - ] - ), - 1 - ), - # A non-string emoji does not count (to test the `isinstance(reaction.emoji, str)` elif) - ( - "Reaction with non-Emoji/str emoij from 3 staffers + 2 non-staffers", - helpers.MockMessage(reactions=[self._get_reaction(emoji=100, staff=3, nonstaff=2)]), - 0 - ), - # We correctly sum when multiple reactions are provided. - ( - "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", - helpers.MockMessage( - reactions=[ - self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), - self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), - ] - ), - 3 + 4 - ), - ) - - for description, message, expected_count in test_cases: - actual_count = await self.cog.count_ducks(message) - with self.subTest(test_case=description, expected_count=expected_count, actual_count=actual_count): - self.assertEqual(expected_count, actual_count) - - async def test_relay_message_correctly_relays_content_and_attachments(self): - """The `relay_message` method should correctly relay message content and attachments.""" - send_webhook_path = f"{MODULE_PATH}.send_webhook" - send_attachments_path = f"{MODULE_PATH}.send_attachments" - author = MagicMock( - display_name="x", - avatar_url="https://" - ) - - self.cog.webhook = helpers.MockAsyncWebhook() - - test_values = ( - (helpers.MockMessage(author=author, clean_content="", attachments=[]), False, False), - (helpers.MockMessage(author=author, clean_content="message", attachments=[]), True, False), - (helpers.MockMessage(author=author, clean_content="", attachments=["attachment"]), False, True), - (helpers.MockMessage(author=author, clean_content="message", attachments=["attachment"]), True, True), - ) - - for message, expect_webhook_call, expect_attachment_call in test_values: - with patch(send_webhook_path, new_callable=AsyncMock) as send_webhook: - with patch(send_attachments_path, new_callable=AsyncMock) as send_attachments: - with self.subTest(clean_content=message.clean_content, attachments=message.attachments): - await self.cog.relay_message(message) - - self.assertEqual(expect_webhook_call, send_webhook.called) - self.assertEqual(expect_attachment_call, send_attachments.called) - - message.add_reaction.assert_called_once_with(self.checkmark_emoji) - - @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) - async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments): - """The `relay_message` method should handle irretrievable attachments.""" - message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) - side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), "")) - - self.cog.webhook = helpers.MockAsyncWebhook() - log = logging.getLogger("bot.cogs.duck_pond") - - for side_effect in side_effects: # pragma: no cover - send_attachments.side_effect = side_effect - with patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) as send_webhook: - with self.subTest(side_effect=type(side_effect).__name__): - with self.assertNotLogs(logger=log, level=logging.ERROR): - await self.cog.relay_message(message) - - self.assertEqual(send_webhook.call_count, 2) - - @patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) - @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) - async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): - """The `relay_message` method should handle irretrievable attachments.""" - message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) - - self.cog.webhook = helpers.MockAsyncWebhook() - log = logging.getLogger("bot.cogs.duck_pond") - - side_effect = discord.HTTPException(MagicMock(), "") - send_attachments.side_effect = side_effect - with self.subTest(side_effect=type(side_effect).__name__): - with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - await self.cog.relay_message(message) - - send_webhook.assert_called_once_with( - webhook=self.cog.webhook, - content=message.clean_content, - username=message.author.display_name, - avatar_url=message.author.avatar_url - ) - - self.assertEqual(len(log_watcher.records), 1) - - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.ERROR) - - def _mock_payload(self, label: str, is_custom_emoji: bool, id_: int, emoji_name: str): - """Creates a mock `on_raw_reaction_add` payload with the specified emoji data.""" - payload = MagicMock(name=label) - payload.emoji.is_custom_emoji.return_value = is_custom_emoji - payload.emoji.id = id_ - payload.emoji.name = emoji_name - return payload - - async def test_payload_has_duckpond_emoji_correctly_detects_relevant_emojis(self): - """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" - test_values = ( - # Custom Emojis - ( - self._mock_payload( - label="Custom Duckpond Emoji", - is_custom_emoji=True, - id_=constants.DuckPond.custom_emojis[0], - emoji_name="" - ), - True - ), - ( - self._mock_payload( - label="Custom Non-Duckpond Emoji", - is_custom_emoji=True, - id_=123, - emoji_name="" - ), - False - ), - # Unicode Emojis - ( - self._mock_payload( - label="Unicode Duck Emoji", - is_custom_emoji=False, - id_=1, - emoji_name=self.unicode_duck_emoji - ), - True - ), - ( - self._mock_payload( - label="Unicode Non-Duck Emoji", - is_custom_emoji=False, - id_=1, - emoji_name=self.thumbs_up_emoji - ), - False - ), - ) - - for payload, expected_return in test_values: - actual_return = self.cog._payload_has_duckpond_emoji(payload) - with self.subTest(case=payload._mock_name, expected_return=expected_return, actual_return=actual_return): - self.assertEqual(expected_return, actual_return) - - @patch(f"{MODULE_PATH}.discord.utils.get") - @patch(f"{MODULE_PATH}.DuckPond._payload_has_duckpond_emoji", new=MagicMock(return_value=False)) - def test_on_raw_reaction_add_returns_early_with_payload_without_duck_emoji(self, utils_get): - """The `on_raw_reaction_add` method should return early if the payload does not contain a duck emoji.""" - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload=MagicMock()))) - - # Ensure we've returned before making an unnecessary API call in the lines of code after the emoji check - utils_get.assert_not_called() - - def _raw_reaction_mocks(self, channel_id, message_id, user_id): - """Sets up mocks for tests of the `on_raw_reaction_add` event listener.""" - channel = helpers.MockTextChannel(id=channel_id) - self.bot.get_all_channels.return_value = (channel,) - - message = helpers.MockMessage(id=message_id) - - channel.fetch_message.return_value = message - - member = helpers.MockMember(id=user_id, roles=[self.staff_role]) - message.guild.members = (member,) - - payload = MagicMock(channel_id=channel_id, message_id=message_id, user_id=user_id) - - return channel, message, member, payload - - async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self): - """The `on_raw_reaction_add` event handler should return for bot users or non-staff members.""" - channel_id = 1234 - message_id = 2345 - user_id = 3456 - - channel, message, _, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) - - test_cases = ( - ("non-staff member", helpers.MockMember(id=user_id)), - ("bot staff member", helpers.MockMember(id=user_id, roles=[self.staff_role], bot=True)), - ) - - payload.emoji = self.duck_pond_emoji - - for description, member in test_cases: - message.guild.members = (member, ) - with self.subTest(test_case=description), patch(f"{MODULE_PATH}.DuckPond.has_green_checkmark") as checkmark: - checkmark.side_effect = AssertionError( - "Expected method to return before calling `self.has_green_checkmark`." - ) - self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) - - # Check that we did make it past the payload checks - channel.fetch_message.assert_called_once() - channel.fetch_message.reset_mock() - - @patch(f"{MODULE_PATH}.DuckPond.is_staff") - @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) - def test_on_raw_reaction_add_returns_on_message_with_green_checkmark_placed_by_bot(self, count_ducks, is_staff): - """The `on_raw_reaction_add` event should return when the message has a green check mark placed by the bot.""" - channel_id = 31415926535 - message_id = 27182818284 - user_id = 16180339887 - - channel, message, member, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) - - payload.emoji = helpers.MockPartialEmoji(name=self.unicode_duck_emoji) - payload.emoji.is_custom_emoji.return_value = False - - message.reactions = [helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.bot.user])] - - is_staff.return_value = True - count_ducks.side_effect = AssertionError("Expected method to return before calling `self.count_ducks`") - - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload))) - - # Assert that we've made it past `self.is_staff` - is_staff.assert_called_once() - - async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self): - """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" - test_cases = ( - (constants.DuckPond.threshold - 1, False), - (constants.DuckPond.threshold, True), - (constants.DuckPond.threshold + 1, True), - ) - - channel, message, member, payload = self._raw_reaction_mocks(channel_id=3, message_id=4, user_id=5) - - payload.emoji = self.duck_pond_emoji - - for duck_count, should_relay in test_cases: - with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=AsyncMock) as relay_message: - with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: - count_ducks.return_value = duck_count - with self.subTest(duck_count=duck_count, should_relay=should_relay): - await self.cog.on_raw_reaction_add(payload) - - # Confirm that we've made it past counting - count_ducks.assert_called_once() - - # Did we relay a message? - has_relayed = relay_message.called - self.assertEqual(has_relayed, should_relay) - - if should_relay: - relay_message.assert_called_once_with(message) - - async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self): - """The `on_raw_reaction_remove` listener prevents removal of the check mark on messages with enough ducks.""" - checkmark = helpers.MockPartialEmoji(name=self.checkmark_emoji) - - message = helpers.MockMessage(id=1234) - - channel = helpers.MockTextChannel(id=98765) - channel.fetch_message.return_value = message - - self.bot.get_all_channels.return_value = (channel, ) - - payload = MagicMock(channel_id=channel.id, message_id=message.id, emoji=checkmark) - - test_cases = ( - (constants.DuckPond.threshold - 1, False), - (constants.DuckPond.threshold, True), - (constants.DuckPond.threshold + 1, True), - ) - for duck_count, should_re_add_checkmark in test_cases: - with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: - count_ducks.return_value = duck_count - with self.subTest(duck_count=duck_count, should_re_add_checkmark=should_re_add_checkmark): - await self.cog.on_raw_reaction_remove(payload) - - # Check if we fetched the message - channel.fetch_message.assert_called_once_with(message.id) - - # Check if we actually counted the number of ducks - count_ducks.assert_called_once_with(message) - - has_re_added_checkmark = message.add_reaction.called - self.assertEqual(should_re_add_checkmark, has_re_added_checkmark) - - if should_re_add_checkmark: - message.add_reaction.assert_called_once_with(self.checkmark_emoji) - message.add_reaction.reset_mock() - - # reset mocks - channel.fetch_message.reset_mock() - message.reset_mock() - - def test_on_raw_reaction_remove_ignores_removal_of_non_checkmark_reactions(self): - """The `on_raw_reaction_remove` listener should ignore the removal of non-check mark emojis.""" - channel = helpers.MockTextChannel(id=98765) - - channel.fetch_message.side_effect = AssertionError( - "Expected method to return before calling `channel.fetch_message`" - ) - - self.bot.get_all_channels.return_value = (channel, ) - - payload = MagicMock(emoji=helpers.MockPartialEmoji(name=self.thumbs_up_emoji), channel_id=channel.id) - - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_remove(payload))) - - channel.fetch_message.assert_not_called() - - -class DuckPondSetupTests(unittest.TestCase): - """Tests setup of the `DuckPond` cog.""" - - def test_setup(self): - """Setup of the extension should call add_cog.""" - bot = helpers.MockBot() - duck_pond.setup(bot) - bot.add_cog.assert_called_once() -- cgit v1.2.3 From 28bfbe65b3fe113948a027970eb7cb22f5b467b9 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 13:27:23 +0200 Subject: Use helper for duckpond's locked relay feature I've created a helper for duckpond's relay feature to allow me to use it separately from the command we're planning to add to the Cog. I opted not to include the lock in the original relay method to separate the logic more clearly. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/duck_pond.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 12f4cb7b8..6156c3238 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -105,6 +105,25 @@ class DuckPond(Cog): except discord.HTTPException: log.exception("Failed to send an attachment to the webhook") + async def locked_relay(self, message: discord.Message) -> bool: + """Relay a message after obtaining the relay lock.""" + if self.relay_lock is None: + # Lazily load the lock to ensure it's created within the + # appropriate event loop. + self.relay_lock = asyncio.Lock() + + async with self.relay_lock: + # check if the message has a checkmark after acquiring the lock + if await self.has_green_checkmark(message): + return False + + # relay the message + await self.relay_message(message) + + # add a green checkmark to indicate that the message was relayed + await message.add_reaction("✅") + return True + def _payload_has_duckpond_emoji(self, emoji: discord.PartialEmoji) -> bool: """Test if the RawReactionActionEvent payload contains a duckpond emoji.""" if emoji.is_unicode_emoji(): @@ -150,21 +169,7 @@ class DuckPond(Cog): # If we've got more than the required amount of ducks, send the message to the duck_pond. if duck_count >= constants.DuckPond.threshold: - if self.relay_lock is None: - # Lazily load the lock to ensure it's created within the - # appropriate event loop. - self.relay_lock = asyncio.Lock() - - async with self.relay_lock: - # check if the message has a checkmark after acquiring the lock - if await self.has_green_checkmark(message): - return - - # relay the message - await self.relay_message(message) - - # add a green checkmark to indicate that the message was relayed - await message.add_reaction("✅") + await self.locked_relay(message) @Cog.listener() async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None: -- cgit v1.2.3 From ef31ddad7a736ccea5662cd1e192d3663f587bd7 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 13:29:00 +0200 Subject: Add command to relay a message to duckpond This commit adds a command that allows admins to manually relay a message to the duckpond, regardless of duck counts and the checks done in the reaction event handler. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/duck_pond.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 6156c3238..2758de8ab 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -4,10 +4,11 @@ from typing import Union import discord from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors -from discord.ext.commands import Cog +from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot +from bot.decorators import with_role from bot.utils.messages import send_attachments from bot.utils.webhooks import send_webhook @@ -183,6 +184,15 @@ class DuckPond(Cog): if duck_count >= constants.DuckPond.threshold: await message.add_reaction("✅") + @command(name="duckify", aliases=("duckpond", "pondify")) + @with_role(constants.Roles.admins) + async def duckify(self, ctx: Context, message: discord.Message) -> None: + """Relay a message to the duckpond, no ducks required!""" + if await self.locked_relay(message): + await ctx.message.add_reaction("🦆") + else: + await ctx.message.add_reaction("❌") + def setup(bot: Bot) -> None: """Load the DuckPond cog.""" -- cgit v1.2.3 From 588213c6209b8df143634f916ff866bea4a9cec3 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 20:26:25 +0200 Subject: Lower duckpond threshold to increase activity There's not a lot of activity in our duckpond at the moment. To activate our duckies, I've decreased the duckpond threshold to 4. This means that a message will now be relayed once it's been ducked four times. Let's get all of our ducks in a row. Signed-off-by: Sebastiaan Zeeff --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 4c1c7e483..fe15e5a87 100644 --- a/config-default.yml +++ b/config-default.yml @@ -463,7 +463,7 @@ sync: max_diff: 10 duck_pond: - threshold: 5 + threshold: 4 channel_blacklist: - *ANNOUNCEMENTS - *PYNEWS_CHANNEL -- cgit v1.2.3 From 00f4e909070adb7916ff25cf8c772c404a50d329 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 21 Sep 2020 20:54:56 -0400 Subject: Use `has_any_role` decorator instead of old `with_role` decorator --- bot/exts/fun/duck_pond.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 2758de8ab..6c2d22b9c 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -8,7 +8,7 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot -from bot.decorators import with_role +from bot.utils.checks import has_any_role from bot.utils.messages import send_attachments from bot.utils.webhooks import send_webhook @@ -185,7 +185,7 @@ class DuckPond(Cog): await message.add_reaction("✅") @command(name="duckify", aliases=("duckpond", "pondify")) - @with_role(constants.Roles.admins) + @has_any_role(constants.Roles.admins) async def duckify(self, ctx: Context, message: discord.Message) -> None: """Relay a message to the duckpond, no ducks required!""" if await self.locked_relay(message): -- cgit v1.2.3