aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/constants.py3
-rw-r--r--bot/exts/fun/duck_pond.py20
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py22
-rw-r--r--bot/exts/recruitment/talentpool/_review.py95
-rw-r--r--bot/utils/messages.py41
-rw-r--r--config-default.yml3
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