Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7f1d319
Add duck-pond constants.
Oct 27, 2019
957f462
Add duck_pond cog.
Oct 27, 2019
38579ad
Appease the linter
Oct 27, 2019
0957bee
Add correct values for constants from production server.
Oct 27, 2019
c66cd6f
Fix broken constant tests
Oct 27, 2019
dac975e
Improve the setup() docstring
lemonsaurus Oct 27, 2019
5162222
Addressing review by Mark.
Oct 27, 2019
08d0c46
Adding kosas additional ducks to default-config
Oct 27, 2019
77429f7
Merge branch 'duck_pond' of github.com:python-discord/bot into duck_pond
Oct 27, 2019
a98485e
Figure out which tests we need.
Oct 31, 2019
ddeeb12
Resolving merge conflicts from master
Nov 3, 2019
acb937d
Test is_staff and has_green_checkmark.
Nov 3, 2019
aac8404
Adding ducky count tests and a new AsyncIteratorMock
Nov 11, 2019
98ccfbc
Implement a mixed duck test.
lemonsaurus Nov 11, 2019
a89349e
Add tests for on_raw_reaction_add.
Nov 12, 2019
654646d
Merging in master
Nov 13, 2019
ccda39c
Add bot=False default value to MockMember
SebastiaanZ Nov 14, 2019
61051f9
Add MockAttachment type and attachments default for MockMessage
SebastiaanZ Nov 14, 2019
8c64fc6
Check only for bot's green checkmark in DuckPond
SebastiaanZ Nov 15, 2019
f56f6ce
Refactor DuckPond msg relay to separate method
SebastiaanZ Nov 15, 2019
89890d6
Move payload checks to start of DuckPond.on_raw_reaction_add
SebastiaanZ Nov 15, 2019
2779a91
Add `return_value` support and assertions to AsyncIteratorMock
SebastiaanZ Nov 15, 2019
2c77288
Add MockUser to mock `discord.User` objects
SebastiaanZ Nov 15, 2019
647370d
Adjust MockReaction for new AsyncIteratorMock protocol
SebastiaanZ Nov 15, 2019
b42a7b5
Add MockAsyncWebhook to mock `discord.Webhook` objects
SebastiaanZ Nov 15, 2019
a692a95
Add unit tests with full coverage for `bot.cogs.duck_pond`
SebastiaanZ Nov 15, 2019
e67822a
Apply suggestions from code review
SebastiaanZ Nov 16, 2019
4be37c1
Move duckpond payload emoji check to method to create testable unit
SebastiaanZ Nov 27, 2019
80d84e1
Apply review comments to duckpond's unit tests
SebastiaanZ Nov 27, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bot/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
bot.load_extension("bot.cogs.alias")
bot.load_extension("bot.cogs.defcon")
bot.load_extension("bot.cogs.eval")
bot.load_extension("bot.cogs.duck_pond")
bot.load_extension("bot.cogs.free")
bot.load_extension("bot.cogs.information")
bot.load_extension("bot.cogs.jams")
Expand Down
182 changes: 182 additions & 0 deletions bot/cogs/duck_pond.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import logging
from typing import Optional, Union

import discord
from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors
from discord.ext.commands import Bot, Cog

from bot import constants
from bot.utils.messages import send_attachments

log = logging.getLogger(__name__)


class DuckPond(Cog):
"""Relays messages to #duck-pond whenever a certain number of duck reactions have been achieved."""

def __init__(self, bot: Bot):
self.bot = bot
self.webhook_id = constants.Webhooks.duck_pond
self.bot.loop.create_task(self.fetch_webhook())

async def fetch_webhook(self) -> None:
"""Fetches the webhook object, so we can post to it."""
await self.bot.wait_until_ready()

try:
self.webhook = await self.bot.fetch_webhook(self.webhook_id)
except discord.HTTPException:
log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`")

@staticmethod
def is_staff(member: Union[User, Member]) -> bool:
"""Check if a specific member or user is staff."""
if hasattr(member, "roles"):
for role in member.roles:
if role.id in constants.STAFF_ROLES:
return True
return False

async def has_green_checkmark(self, message: Message) -> bool:
"""Check if the message has a green checkmark reaction."""
for reaction in message.reactions:
if reaction.emoji == "✅":
async for user in reaction.users():
if user == self.bot.user:
return True
return False

async def send_webhook(
self,
content: Optional[str] = None,
username: Optional[str] = None,
avatar_url: Optional[str] = None,
embed: Optional[Embed] = None,
) -> None:
"""Send a webhook to the duck_pond channel."""
try:
await self.webhook.send(
content=content,
username=username,
avatar_url=avatar_url,
embed=embed
)
except discord.HTTPException:
log.exception("Failed to send a message to the Duck Pool webhook")

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 = []

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

async def relay_message(self, message: Message) -> None:
"""Relays the message's content and attachments to the duck pond channel."""
clean_content = message.clean_content

if clean_content:
await self.send_webhook(
content=message.clean_content,
username=message.author.display_name,
avatar_url=message.author.avatar_url
)

if message.attachments:
try:
await send_attachments(message, self.webhook)
except (errors.Forbidden, errors.NotFound):
e = Embed(
description=":x: **This message contained an attachment, but it could not be retrieved**",
color=Color.red()
)
await self.send_webhook(
embed=e,
username=message.author.display_name,
avatar_url=message.author.avatar_url
)
except discord.HTTPException:
log.exception(f"Failed to send an attachment to the webhook")

await message.add_reaction("✅")

@staticmethod
def _payload_has_duckpond_emoji(payload: RawReactionActionEvent) -> 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

return False

@Cog.listener()
async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None:
"""
Determine if a message should be sent to the duck pond.

This will count the number of duck reactions on the message, and if this amount meets the
amount of ducks specified in the config under duck_pond/threshold, it will
send the message off to the duck pond.
"""
# Is the emoji in the reaction a duck?
if not self._payload_has_duckpond_emoji(payload):
return

channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id)
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?
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)

@Cog.listener()
async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None:
Comment thread
SebastiaanZ marked this conversation as resolved.
"""Ensure that people don't remove the green checkmark from duck ponded messages."""
channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id)

# Prevent the green checkmark from being removed
if payload.emoji.name == "✅":
message = await channel.fetch_message(payload.message_id)
duck_count = await self.count_ducks(message)
if duck_count >= constants.DuckPond.threshold:
await message.add_reaction("✅")


def setup(bot: Bot) -> None:
"""Load the duck pond cog."""
bot.add_cog(DuckPond(bot))
log.info("Cog loaded: DuckPond")
69 changes: 40 additions & 29 deletions bot/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,13 @@ class Colours(metaclass=YAMLGetter):
soft_orange: int


class DuckPond(metaclass=YAMLGetter):
section = "duck_pond"

threshold: int
custom_emojis: List[int]


class Emojis(metaclass=YAMLGetter):
section = "style"
subsection = "emojis"
Expand All @@ -244,11 +251,6 @@ class Emojis(metaclass=YAMLGetter):
defcon_enabled: str # noqa: E704
defcon_updated: str # noqa: E704

green_chevron: str
red_chevron: str
white_chevron: str
bb_message: str

status_online: str
status_offline: str
status_idle: str
Expand All @@ -259,6 +261,14 @@ class Emojis(metaclass=YAMLGetter):
pencil: str
cross_mark: str

ducky_yellow: int
ducky_blurple: int
ducky_regal: int
ducky_camo: int
ducky_ninja: int
ducky_devil: int
ducky_tube: int

upvotes: str
comments: str
user: str
Expand Down Expand Up @@ -377,6 +387,7 @@ class Webhooks(metaclass=YAMLGetter):
talent_pool: int
big_brother: int
reddit: int
duck_pond: int


class Roles(metaclass=YAMLGetter):
Expand Down Expand Up @@ -508,6 +519,30 @@ class RedirectOutput(metaclass=YAMLGetter):
delete_delay: int


class Event(Enum):
Comment thread
MarkKoz marked this conversation as resolved.
"""
Event names. This does not include every event (for example, raw
events aren't here), but only events used in ModLog for now.
"""

guild_channel_create = "guild_channel_create"
guild_channel_delete = "guild_channel_delete"
guild_channel_update = "guild_channel_update"
guild_role_create = "guild_role_create"
guild_role_delete = "guild_role_delete"
guild_role_update = "guild_role_update"
guild_update = "guild_update"

member_join = "member_join"
member_remove = "member_remove"
member_ban = "member_ban"
member_unban = "member_unban"
member_update = "member_update"

message_delete = "message_delete"
message_edit = "message_edit"


# Debug mode
DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False

Expand Down Expand Up @@ -579,27 +614,3 @@ class RedirectOutput(metaclass=YAMLGetter):
"Noooooo!!",
"I can't believe you've done this",
]


class Event(Enum):
"""
Event names. This does not include every event (for example, raw
events aren't here), but only events used in ModLog for now.
"""

guild_channel_create = "guild_channel_create"
guild_channel_delete = "guild_channel_delete"
guild_channel_update = "guild_channel_update"
guild_role_create = "guild_role_create"
guild_role_delete = "guild_role_delete"
guild_role_update = "guild_role_update"
guild_update = "guild_update"

member_join = "member_join"
member_remove = "member_remove"
member_ban = "member_ban"
member_unban = "member_unban"
member_update = "member_update"

message_delete = "message_delete"
message_edit = "message_edit"
18 changes: 13 additions & 5 deletions config-default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,6 @@ style:
defcon_enabled: "<:defconenabled:470326274213150730>"
defcon_updated: "<:defconsettingsupdated:470326274082996224>"

green_chevron: "<:greenchevron:418104310329769993>"
red_chevron: "<:redchevron:418112778184818698>"
white_chevron: "<:whitechevron:418110396973711363>"
bb_message: "<:bbmessage:476273120999636992>"

status_online: "<:status_online:470326272351010816>"
status_idle: "<:status_idle:470326266625785866>"
status_dnd: "<:status_dnd:470326272082313216>"
Expand All @@ -37,6 +32,14 @@ style:
new: "\U0001F195"
cross_mark: "\u274C"

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

upvotes: "<:upvotes:638729835245731840>"
comments: "<:comments:638729835073765387>"
user: "<:user:638729835442602003>"
Expand Down Expand Up @@ -155,6 +158,7 @@ guild:
talent_pool: 569145364800602132
big_brother: 569133704568373283
reddit: 635408384794951680
duck_pond: 637821475327311927


filter:
Expand Down Expand Up @@ -389,5 +393,9 @@ redirect_output:
delete_invocation: true
delete_delay: 15

duck_pond:
threshold: 5
custom_emojis: [*DUCKY_YELLOW, *DUCKY_BLURPLE, *DUCKY_CAMO, *DUCKY_DEVIL, *DUCKY_NINJA, *DUCKY_REGAL, *DUCKY_TUBE]

config:
required_keys: ['bot.token']
1 change: 1 addition & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ We are using the following modules and packages for our unit tests:
To ensure the results you obtain on your personal machine are comparable to those generated in the Azure pipeline, please make sure to run your tests with the virtual environment defined by our [Pipfile](/Pipfile). To run your tests with `pipenv`, we've provided two "scripts" shortcuts:

- `pipenv run test` will run `unittest` with `coverage.py`
- `pipenv run test path/to/test.py` will run a specific test.
- `pipenv run report` will generate a coverage report of the tests you've run with `pipenv run test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report.

If you want a coverage report, make sure to run the tests with `pipenv run test` *first*.
Expand Down
Loading