diff options
author | 2021-05-16 19:02:55 +0100 | |
---|---|---|
committer | 2021-05-16 19:02:55 +0100 | |
commit | fea8300a7d84c0efec04bd1315fb3dfc48cbd488 (patch) | |
tree | 9bdeaba014bd102704d870a9f82643d76306b575 | |
parent | Merge pull request #1589 from python-discord/fix-dockerfile (diff) | |
parent | Merge branch 'main' into nomination-archive-automation (diff) |
Merge pull request #1549 from python-discord/nomination-archive-automation
Nomination archive automation
-rw-r--r-- | bot/constants.py | 3 | ||||
-rw-r--r-- | bot/exts/fun/duck_pond.py | 20 | ||||
-rw-r--r-- | bot/exts/recruitment/talentpool/_cog.py | 22 | ||||
-rw-r--r-- | bot/exts/recruitment/talentpool/_review.py | 95 | ||||
-rw-r--r-- | bot/utils/messages.py | 41 | ||||
-rw-r--r-- | config-default.yml | 3 |
6 files changed, 164 insertions, 20 deletions
diff --git a/bot/constants.py b/bot/constants.py index 1ac406e37..885b5c822 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -305,6 +305,8 @@ class Emojis(metaclass=YAMLGetter): status_offline: str status_online: str + ducky_dave: str + trashcan: str bullet: str @@ -423,6 +425,7 @@ class Channels(metaclass=YAMLGetter): attachment_log: int message_log: int mod_log: int + nomination_archive: int user_log: int voice_log: int diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index ee440dec2..c78b9c141 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -9,7 +9,7 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot from bot.utils.checks import has_any_role -from bot.utils.messages import send_attachments +from bot.utils.messages import count_unique_users_reaction, send_attachments from bot.utils.webhooks import send_webhook log = logging.getLogger(__name__) @@ -78,18 +78,12 @@ class DuckPond(Cog): Only counts ducks added by staff members. """ - duck_reactors = set() - - # iterate over all reactions - for reaction in message.reactions: - # 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) + return await count_unique_users_reaction( + message, + lambda r: self._is_duck_emoji(r.emoji), + self.is_staff, + False + ) async def relay_message(self, message: Message) -> None: """Relays the message's content and attachments to the duck pond channel.""" diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 72604be51..03326cab2 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -5,7 +5,7 @@ from io import StringIO from typing import Union import discord -from discord import Color, Embed, Member, User +from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User from discord.ext.commands import Cog, Context, group, has_any_role from bot.api import ResponseCodeError @@ -360,6 +360,26 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """Remove `user` from the talent pool after they are banned.""" await self.unwatch(user.id, "User was banned.") + @Cog.listener() + async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: + """ + Watch for reactions in the #nomination-voting channel to automate it. + + Adding a ticket emoji will unpin the message. + Adding an incident reaction will archive the message. + """ + if payload.channel_id != Channels.nomination_voting: + return + + message: PartialMessage = self.bot.get_channel(payload.channel_id).get_partial_message(payload.message_id) + emoji = str(payload.emoji) + + if emoji == "\N{TICKET}": + await message.unpin(reason="Admin task created.") + elif emoji in {Emojis.incident_actioned, Emojis.incident_unactioned}: + log.info(f"Archiving nomination {message.id}") + await self.reviewer.archive_vote(message, emoji == Emojis.incident_actioned) + async def unwatch(self, user_id: int, reason: str) -> bool: """End the active nomination of a user with the given reason and return True on success.""" active_nomination = await self.bot.api_client.get( diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 4ae1c5ad6..d53c3b074 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -1,6 +1,8 @@ import asyncio +import contextlib import logging import random +import re import textwrap import typing from collections import Counter @@ -9,12 +11,13 @@ from typing import List, Optional, Union from dateutil.parser import isoparse from dateutil.relativedelta import relativedelta -from discord import Emoji, Member, Message, TextChannel +from discord import Embed, Emoji, Member, Message, NoMoreItems, PartialMessage, TextChannel from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Guild, Roles +from bot.constants import Channels, Colours, Emojis, Guild, Roles +from bot.utils.messages import count_unique_users_reaction, pin_no_system_message from bot.utils.scheduling import Scheduler from bot.utils.time import get_time_delta, humanize_delta, time_since @@ -29,6 +32,11 @@ MAX_DAYS_IN_POOL = 30 # Maximum amount of characters allowed in a message MAX_MESSAGE_SIZE = 2000 +# Regex finding the user ID of a user mention +MENTION_RE = re.compile(r"<@!?(\d+?)>") +# Regex matching role pings +ROLE_MENTION_RE = re.compile(r"<@&\d+>") + class Reviewer: """Schedules, formats, and publishes reviews of helper nominees.""" @@ -75,10 +83,14 @@ class Reviewer: channel = guild.get_channel(Channels.nomination_voting) log.trace(f"Posting the review of {user_id}") - message = (await self._bulk_send(channel, review))[-1] + messages = await self._bulk_send(channel, review) + + await pin_no_system_message(messages[0]) + + last_message = messages[-1] if seen_emoji: for reaction in (seen_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"): - await message.add_reaction(reaction) + await last_message.add_reaction(reaction) if update_database: nomination = self._pool.watched_users.get(user_id) @@ -105,7 +117,7 @@ class Reviewer: f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server :pensive:" ), None - opening = f"<@&{Roles.moderators}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!" + opening = f"<@&{Roles.mod_team}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!" current_nominations = "\n\n".join( f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}" for entry in nomination['entries'] @@ -124,6 +136,79 @@ class Reviewer: review = "\n\n".join((opening, current_nominations, review_body, vote_request)) return review, seen_emoji + async def archive_vote(self, message: PartialMessage, passed: bool) -> None: + """Archive this vote to #nomination-archive.""" + message = await message.fetch() + + # We consider the first message in the nomination to contain the two role pings + messages = [message] + if not len(ROLE_MENTION_RE.findall(message.content)) >= 2: + with contextlib.suppress(NoMoreItems): + async for new_message in message.channel.history(before=message.created_at): + messages.append(new_message) + + if len(ROLE_MENTION_RE.findall(new_message.content)) >= 2: + break + + log.debug(f"Found {len(messages)} messages: {', '.join(str(m.id) for m in messages)}") + + parts = [] + for message_ in messages[::-1]: + parts.append(message_.content) + parts.append("\n" if message_.content.endswith(".") else " ") + content = "".join(parts) + + # We assume that the first user mentioned is the user that we are voting on + user_id = int(MENTION_RE.search(content).group(1)) + + # Get reaction counts + seen = await count_unique_users_reaction( + messages[0], + lambda r: "ducky" in str(r) or str(r) == "\N{EYES}", + count_bots=False + ) + upvotes = await count_unique_users_reaction( + messages[0], + lambda r: str(r) == "\N{THUMBS UP SIGN}", + count_bots=False + ) + downvotes = await count_unique_users_reaction( + messages[0], + lambda r: str(r) == "\N{THUMBS DOWN SIGN}", + count_bots=False + ) + + # Remove the first and last paragraphs + stripped_content = content.split("\n\n", maxsplit=1)[1].rsplit("\n\n", maxsplit=1)[0] + + result = f"**Passed** {Emojis.incident_actioned}" if passed else f"**Rejected** {Emojis.incident_unactioned}" + colour = Colours.soft_green if passed else Colours.soft_red + timestamp = datetime.utcnow().strftime("%Y/%m/%d") + + embed_content = ( + f"{result} on {timestamp}\n" + f"With {seen} {Emojis.ducky_dave} {upvotes} :+1: {downvotes} :-1:\n\n" + f"{stripped_content}" + ) + + if user := await self.bot.fetch_user(user_id): + embed_title = f"Vote for {user} (`{user.id}`)" + else: + embed_title = f"Vote for `{user_id}`" + + channel = self.bot.get_channel(Channels.nomination_archive) + for number, part in enumerate( + textwrap.wrap(embed_content, width=MAX_MESSAGE_SIZE, replace_whitespace=False, placeholder="") + ): + await channel.send(embed=Embed( + title=embed_title if number == 0 else None, + description="[...] " + part if number != 0 else part, + colour=colour + )) + + for message_ in messages: + await message_.delete() + async def _construct_review_body(self, member: Member) -> str: """Formats the body of the nomination, with details of activity, infractions, and previous nominations.""" activity = await self._activity_review(member) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 2beead6af..b6f6c1f66 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -5,9 +5,10 @@ import random import re from functools import partial from io import BytesIO -from typing import List, Optional, Sequence, Union +from typing import Callable, List, Optional, Sequence, Union import discord +from discord import Message, MessageType, Reaction, User from discord.errors import HTTPException from discord.ext.commands import Context @@ -164,6 +165,44 @@ async def send_attachments( return urls +async def count_unique_users_reaction( + message: discord.Message, + reaction_predicate: Callable[[Reaction], bool] = lambda _: True, + user_predicate: Callable[[User], bool] = lambda _: True, + count_bots: bool = True +) -> int: + """ + Count the amount of unique users who reacted to the message. + + A reaction_predicate function can be passed to check if this reaction should be counted, + another user_predicate to check if the user should also be counted along with a count_bot flag. + """ + unique_users = set() + + for reaction in message.reactions: + if reaction_predicate(reaction): + async for user in reaction.users(): + if (count_bots or not user.bot) and user_predicate(user): + unique_users.add(user.id) + + return len(unique_users) + + +async def pin_no_system_message(message: Message) -> bool: + """Pin the given message, wait a couple of seconds and try to delete the system message.""" + await message.pin() + + # Make sure that we give it enough time to deliver the message + await asyncio.sleep(2) + # Search for the system message in the last 10 messages + async for historical_message in message.channel.history(limit=10): + if historical_message.type == MessageType.pins_add: + await historical_message.delete() + return True + + return False + + def sub_clyde(username: Optional[str]) -> Optional[str]: """ Replace "e"/"E" in any "clyde" in `username` with a Cyrillic "ะต"/"E" and return the new string. diff --git a/config-default.yml b/config-default.yml index c59bff524..30626c811 100644 --- a/config-default.yml +++ b/config-default.yml @@ -65,6 +65,8 @@ style: status_offline: "<:status_offline:470326266537705472>" status_online: "<:status_online:470326272351010816>" + ducky_dave: "<:ducky_dave:742058418692423772>" + trashcan: "<:trashcan:637136429717389331>" bullet: "\u2022" @@ -170,6 +172,7 @@ guild: attachment_log: &ATTACH_LOG 649243850006855680 message_log: &MESSAGE_LOG 467752170159079424 mod_log: &MOD_LOG 282638479504965634 + nomination_archive: 833371042046148738 user_log: 528976905546760203 voice_log: 640292421988646961 |