aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Joe Banks <[email protected]>2024-05-13 16:37:42 +0100
committerGravatar GitHub <[email protected]>2024-05-13 16:37:42 +0100
commit72b642b09e58baa57b113399c7024f2fcfd6d4fa (patch)
tree9d34e708e9ceb44f29dde03efa375b8f3797da36
parentBump ruff from 0.4.3 to 0.4.4 (#3043) (diff)
parentMove from sentry_sdk.push_scope to sentry_sdk.new_scope (diff)
Merge pull request #3047 from python-discord/jb3/2938-nominate-context-menu
Add context menu command for user nomination
-rw-r--r--bot/bot.py4
-rw-r--r--bot/exts/backend/error_handler.py4
-rw-r--r--bot/exts/recruitment/talentpool/_api.py11
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py154
-rw-r--r--tests/bot/exts/backend/test_error_handler.py10
5 files changed, 173 insertions, 10 deletions
diff --git a/bot/bot.py b/bot/bot.py
index fa3617f1b..35dbd1ba4 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -6,7 +6,7 @@ import aiohttp
from discord.errors import Forbidden
from pydis_core import BotBase
from pydis_core.utils.error_handling import handle_forbidden_from_block
-from sentry_sdk import push_scope, start_transaction
+from sentry_sdk import new_scope, start_transaction
from bot import constants, exts
from bot.log import get_logger
@@ -71,7 +71,7 @@ class Bot(BotBase):
self.stats.incr(f"errors.event.{event}")
- with push_scope() as scope:
+ with new_scope() as scope:
scope.set_tag("event", event)
scope.set_extra("args", args)
scope.set_extra("kwargs", kwargs)
diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py
index 5cf07613d..352be313d 100644
--- a/bot/exts/backend/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -7,7 +7,7 @@ from discord.ext.commands import ChannelNotFound, Cog, Context, TextChannelConve
from pydis_core.site_api import ResponseCodeError
from pydis_core.utils.error_handling import handle_forbidden_from_block
from pydis_core.utils.interactions import DeleteMessageButton, ViewWithUserAndRoleCheck
-from sentry_sdk import push_scope
+from sentry_sdk import new_scope
from bot.bot import Bot
from bot.constants import Colours, Icons, MODERATION_ROLES
@@ -400,7 +400,7 @@ class ErrorHandler(Cog):
ctx.bot.stats.incr("errors.unexpected")
- with push_scope() as scope:
+ with new_scope() as scope:
scope.user = {
"id": ctx.author.id,
"username": str(ctx.author)
diff --git a/bot/exts/recruitment/talentpool/_api.py b/bot/exts/recruitment/talentpool/_api.py
index f7b243209..8cd8eb56a 100644
--- a/bot/exts/recruitment/talentpool/_api.py
+++ b/bot/exts/recruitment/talentpool/_api.py
@@ -59,6 +59,17 @@ class NominationAPI:
nomination = Nomination.model_validate(data)
return nomination
+ async def get_nomination_reason(self, user_id: int, actor_id: int) -> tuple[Nomination, str] | None:
+ """Search for a nomination & reason for a specific actor on a specific user."""
+ nominations = await self.get_nominations(user_id, True)
+
+ for nomination in nominations:
+ for entry in nomination.entries:
+ if entry.actor_id == actor_id:
+ return nomination, entry.reason
+
+ return None
+
async def edit_nomination(
self,
nomination_id: int,
diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py
index 78084899f..ad232df8c 100644
--- a/bot/exts/recruitment/talentpool/_cog.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -5,12 +5,13 @@ from io import StringIO
import discord
from async_rediscache import RedisCache
-from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User
+from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User, app_commands
from discord.ext import commands, tasks
from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role
from pydis_core.site_api import ResponseCodeError
from pydis_core.utils.channel import get_or_fetch_channel
from pydis_core.utils.members import get_or_fetch_member
+from sentry_sdk import new_scope
from bot.bot import Bot
from bot.constants import Bot as BotConfig, Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES
@@ -30,6 +31,66 @@ DAYS_UNTIL_INACTIVE = 45
log = get_logger(__name__)
+class NominationContextModal(discord.ui.Modal, title="New Nomination"):
+ nomination = discord.ui.TextInput(
+ label="Additional nomination context:",
+ style=discord.TextStyle.long,
+ placeholder="Additional context for nomination...",
+ required=False,
+ # Take some length off for the URL that is going to be appended
+ max_length=REASON_MAX_CHARS - 110
+ )
+
+ def __init__(self, api: NominationAPI, message: discord.Message, noms_channel: discord.TextChannel):
+ self.message = message
+ self.api = api
+ self.noms_channel = noms_channel
+
+ super().__init__()
+
+ async def on_submit(self, interaction: discord.Interaction) -> None:
+ reason = ""
+
+ if self.nomination.value and self.nomination.value != "":
+ reason += f"{self.nomination.value}\n\n"
+
+ reason += f"Nominated from: {self.message.jump_url}"
+
+ try:
+ await self.api.post_nomination(self.message.author.id, interaction.user.id, reason)
+ except ResponseCodeError as e:
+ match (e.status, e.response_json):
+ case (400, {"user": _}):
+ await interaction.response.send_message(
+ f":x: {self.target.mention} can't be found in the database tables.",
+ ephemeral=True
+ )
+ log.warning(f"Could not find {self.target.author} in the site database tables, sync may be broken")
+ return
+
+ raise e
+
+ await interaction.response.send_message(
+ f":white_check_mark: The nomination for {self.message.author.mention}"
+ " has been added to the talent pool",
+ ephemeral=True
+ )
+
+ noms_channel_message = (
+ f":star: {interaction.user.mention} has nominated "
+ f"{self.message.author.mention} from {self.message.jump_url}"
+ )
+
+ if self.nomination.value and self.nomination.value != "":
+ noms_channel_message += "\n```\n"
+ noms_channel_message += self.nomination.value
+ noms_channel_message += "```"
+
+ await self.noms_channel.send(noms_channel_message)
+
+ async def on_error(self, interaction: discord.Interaction, error: Exception) -> None:
+ """Handle any exceptions in the processing of the modal."""
+ await TalentPool._nominate_context_error(interaction, error)
class TalentPool(Cog, name="Talentpool"):
"""Used to nominate potential helper candidates."""
@@ -45,6 +106,15 @@ class TalentPool(Cog, name="Talentpool"):
# This lock lets us avoid cancelling the reviewer loop while the review code is running.
self.autoreview_lock = asyncio.Lock()
+ # Setup the context menu command for message-based nomination
+ self.nominate_user_context_menu = app_commands.ContextMenu(
+ name="Nominate User",
+ callback=self._nominate_context_callback,
+ )
+ self.nominate_user_context_menu.default_permissions = discord.Permissions.none()
+ self.nominate_user_context_menu.error(self._nominate_context_error)
+ self.bot.tree.add_command(self.nominate_user_context_menu, guild=discord.Object(Guild.id))
+
async def cog_load(self) -> None:
"""Start autoreview loop if enabled."""
if await self.autoreview_enabled():
@@ -338,6 +408,82 @@ class TalentPool(Cog, name="Talentpool"):
await self._nominate_user(ctx, user, reason)
+ @app_commands.checks.has_any_role(*STAFF_ROLES)
+ async def _nominate_context_callback(self, interaction: discord.Interaction, message: discord.Message) -> None:
+ """Create or update a nomination for the author of the selected message."""
+ if message.author.bot:
+ await interaction.response.send_message(
+ ":x: I'm afraid I can't do that. Only humans can be nominated.",
+ ephemeral=True
+ )
+ return
+
+ if isinstance(message.author, Member) and any(role.id in STAFF_ROLES for role in message.author.roles):
+ await interaction.response.send_message(
+ ":x: Nominating staff members, eh? Here's a cookie :cookie:",
+ ephemeral=True
+ )
+ return
+
+ maybe_nom = await self.api.get_nomination_reason(message.author.id, interaction.user.id)
+
+ if maybe_nom:
+ nomination, reason = maybe_nom
+
+ reason += f"\n\n{message.jump_url}"
+
+ await self.api.edit_nomination_entry(
+ nomination.id,
+ actor_id=interaction.user.id,
+ reason=reason
+ )
+
+ await interaction.response.send_message(
+ ":white_check_mark: Existing nomination updated",
+ ephemeral=True
+ )
+ return
+
+ nominations_channel = self.bot.get_channel(Channels.nominations)
+
+ await interaction.response.send_modal(NominationContextModal(self.api, message, nominations_channel))
+
+ @staticmethod
+ async def _nominate_context_error(
+ interaction: discord.Interaction,
+ error: app_commands.AppCommandError
+ ) -> None:
+ """Handle any errors that occur with the nomination context command."""
+ if isinstance(error, app_commands.errors.MissingAnyRole):
+ await interaction.response.send_message(
+ ":x: You do not have permission to use this command", ephemeral=True
+ )
+ return
+
+ message = (
+ f":x: An unexpected error occured, Please let us know!\n\n"
+ f"```{error.__class__.__name__}: {error}```"
+ )
+
+ if not interaction.response.is_done():
+ await interaction.response.send_message(message, ephemeral=True)
+ else:
+ await interaction.followup.send(message, ephemeral=True)
+
+ with new_scope() as scope:
+ scope.user = {
+ "id": interaction.user.id,
+ "username": str(interaction.user)
+ }
+
+ scope.set_tag("command", interaction.command.name)
+ scope.set_extra("interaction_data", interaction.data)
+
+ log.error(
+ f"Error executing application command '{interaction.command.name}' invoked by {interaction.user}",
+ exc_info=error
+ )
+
async def _nominate_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None:
"""Adds the given user to the talent pool."""
if user.bot:
@@ -660,3 +806,9 @@ class TalentPool(Cog, name="Talentpool"):
self.autoreview_loop.cancel()
self.prune_talentpool.cancel()
+
+ self.bot.tree.remove_command(
+ self.nominate_user_context_menu.name,
+ guild=discord.Object(Guild.id),
+ type=self.nominate_user_context_menu.type
+ )
diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py
index dbc62270b..85dc33999 100644
--- a/tests/bot/exts/backend/test_error_handler.py
+++ b/tests/bot/exts/backend/test_error_handler.py
@@ -495,26 +495,26 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
else:
log_mock.debug.assert_called_once()
- @patch("bot.exts.backend.error_handler.push_scope")
+ @patch("bot.exts.backend.error_handler.new_scope")
@patch("bot.exts.backend.error_handler.log")
- async def test_handle_unexpected_error(self, log_mock, push_scope_mock):
+ async def test_handle_unexpected_error(self, log_mock, new_scope_mock):
"""Should `ctx.send` this error, error log this and sent to Sentry."""
for case in (None, MockGuild()):
with self.subTest(guild=case):
self.ctx.reset_mock()
log_mock.reset_mock()
- push_scope_mock.reset_mock()
+ new_scope_mock.reset_mock()
scope_mock = Mock()
# Mock `with push_scope_mock() as scope:`
- push_scope_mock.return_value.__enter__.return_value = scope_mock
+ new_scope_mock.return_value.__enter__.return_value = scope_mock
self.ctx.guild = case
await self.cog.handle_unexpected_error(self.ctx, errors.CommandError())
self.ctx.send.assert_awaited_once()
log_mock.error.assert_called_once()
- push_scope_mock.assert_called_once()
+ new_scope_mock.assert_called_once()
set_tag_calls = [
call("command", self.ctx.command.qualified_name),