diff --git a/cogs/__init__.py b/cogs/__init__.py index 0c74de61..d7cb9e90 100644 --- a/cogs/__init__.py +++ b/cogs/__init__.py @@ -35,7 +35,7 @@ from .invite_link import InviteLinkCommandCog from .kill import KillCommandCog from .make_applicant import MakeApplicantContextCommandsCog, MakeApplicantSlashCommandCog -from .make_member import MakeMemberCommandCog, MemberCountCommandCog +from .make_member import MakeMemberCommandCog, MakeMemberModalCommandCog, MemberCountCommandCog from .ping import PingCommandCog from .remind_me import ClearRemindersBacklogTaskCog, RemindMeCommandCog from .send_get_roles_reminders import SendGetRolesRemindersTaskCog @@ -75,6 +75,7 @@ "MakeApplicantContextCommandsCog", "MakeApplicantSlashCommandCog", "MakeMemberCommandCog", + "MakeMemberModalCommandCog", "ManualModerationCog", "MemberCountCommandCog", "PingCommandCog", @@ -116,6 +117,7 @@ def setup(bot: "TeXBot") -> None: MakeApplicantContextCommandsCog, MakeApplicantSlashCommandCog, MakeMemberCommandCog, + MakeMemberModalCommandCog, ManualModerationCog, MemberCountCommandCog, PingCommandCog, diff --git a/cogs/make_member.py b/cogs/make_member.py index 6139b209..421adf75 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -2,26 +2,34 @@ import logging import re -from typing import TYPE_CHECKING +from enum import Enum, auto +from typing import TYPE_CHECKING, override import discord +from discord.ui import Modal, View from django.core.exceptions import ValidationError from config import settings from db.core.models import GroupMadeMember from exceptions import ApplicantRoleDoesNotExistError, GuestRoleDoesNotExistError from utils import CommandChecks, TeXBotBaseCog -from utils.msl import fetch_community_group_members_count, is_id_a_community_group_member +from utils.msl import ( + fetch_community_group_members_count, + is_id_a_community_group_member, +) if TYPE_CHECKING: from collections.abc import Sequence from logging import Logger from typing import Final - from utils import TeXBotApplicationContext - + from utils import TeXBot, TeXBotApplicationContext -__all__: "Sequence[str]" = ("MakeMemberCommandCog", "MemberCountCommandCog") +__all__: "Sequence[str]" = ( + "MakeMemberCommandCog", + "MakeMemberModalCommandCog", + "MemberCountCommandCog", +) logger: "Final[Logger]" = logging.getLogger("TeX-Bot") @@ -51,7 +59,115 @@ ) -class MakeMemberCommandCog(TeXBotBaseCog): +class MakeMemberStatus(Enum): + """An Enum representing the possible statuses of a make member attempt.""" + + SUCCESS = auto() + ERROR = auto() + INFO = auto() + + +class MakeMemberBaseCog(TeXBotBaseCog): + """Base cog class for make member interactions.""" + + async def perform_make_member( + self, user: discord.User | discord.Member, raw_group_member_id: str + ) -> tuple[MakeMemberStatus, str]: + """Perform the actions to make a user a member.""" + member_role: discord.Role = await self.bot.member_role + discord_member: discord.Member = await self.bot.get_main_guild_member(user) + + INVALID_GROUP_MEMBER_ID_MESSAGE: Final[str] = ( + f"{raw_group_member_id!r} is not a valid {self.bot.group_member_id_type} ID." + ) + + if not re.fullmatch(r"\A\d{7}\Z", raw_group_member_id): + return MakeMemberStatus.ERROR, INVALID_GROUP_MEMBER_ID_MESSAGE + + try: + group_member_id: int = int(raw_group_member_id) + except ValueError: + return MakeMemberStatus.ERROR, INVALID_GROUP_MEMBER_ID_MESSAGE + + if member_role in discord_member.roles: + return ( + MakeMemberStatus.INFO, + ":information_source: No changes made. " + "You're already a member - why are you trying this again? " + ":information_source:", + ) + + if await GroupMadeMember.objects.filter( + hashed_group_member_id=GroupMadeMember.hash_group_member_id( + group_member_id, self.bot.group_member_id_type + ) + ).aexists(): + return ( + MakeMemberStatus.INFO, + ":information_source: This student ID has already been used. " + "Please contact a committee member if you think this is a mistake." + " :information_source:", + ) + + if not await is_id_a_community_group_member(member_id=group_member_id): + return ( + MakeMemberStatus.INFO, + f"You must be a member of {self.bot.group_full_name} " + "to use this command.\n" + f"The provided {_GROUP_MEMBER_ID_ARGUMENT_NAME} must match " + f"the {self.bot.group_member_id_type} ID " + f"that you purchased your {self.bot.group_short_name} membership with.", + ) + + await discord_member.add_roles( + member_role, reason=f"{discord_member} used TeX-Bot to become a member" + ) + + try: + await GroupMadeMember.objects.acreate(group_member_id=raw_group_member_id) # type: ignore[misc] + except ValidationError as create_group_made_member_error: + error_is_already_exists: bool = ( + "hashed_group_member_id" in create_group_made_member_error.message_dict + and any( + "already exists" in error + for error in create_group_made_member_error.message_dict[ + "hashed_group_member_id" + ] + ) + ) + if not error_is_already_exists: + raise + + try: + guest_role: discord.Role = await self.bot.guest_role + except GuestRoleDoesNotExistError: + logger.warning( + '"/make-member" command used but the "Guest" role does not exist. ' + 'Some user\'s may now have the "Member" role without the "Guest" role. ' + 'Use the "/ensure-members-inducted" command to fix this issue.' + ) + else: + if guest_role not in discord_member.roles: + await discord_member.add_roles( + guest_role, + reason=f"{discord_member} used TeX-Bot to become a member.", + ) + + try: + applicant_role: discord.Role = await self.bot.applicant_role + except ApplicantRoleDoesNotExistError: + pass + else: + if applicant_role in discord_member.roles: + await discord_member.remove_roles( + applicant_role, + reason=f"{discord_member} used TeX-Bot to become a member.", + ) + + return MakeMemberStatus.SUCCESS, ":information_source: Successfully made you a member!" + + +class MakeMemberCommandCog(MakeMemberBaseCog): """Cog class that defines the "/make-member" command and its call-back method.""" @discord.slash_command( @@ -100,112 +216,18 @@ async def make_member( has purchased a valid membership to your community group, then gives the member the "Member" role. """ - # NOTE: Shortcut accessors are placed at the top of the function so that the exceptions they raise are displayed before any further errors may be sent - member_role: discord.Role = await self.bot.member_role - interaction_member: discord.Member = await ctx.bot.get_main_guild_member(ctx.user) - - INVALID_GROUP_MEMBER_ID_MESSAGE: Final[str] = ( - f"{raw_group_member_id!r} is not a valid {self.bot.group_member_id_type} ID." - ) - - if not re.fullmatch(r"\A\d{7}\Z", raw_group_member_id): - await self.command_send_error(ctx, message=(INVALID_GROUP_MEMBER_ID_MESSAGE)) - return - - try: - group_member_id: int = int(raw_group_member_id) - except ValueError: - await self.command_send_error(ctx, message=INVALID_GROUP_MEMBER_ID_MESSAGE) - return - await ctx.defer(ephemeral=True) - async with ctx.typing(): - if member_role in interaction_member.roles: - await ctx.followup.send( - content=( - ":information_source: No changes made. You're already a member " - "- why are you trying this again? :information_source:" - ), - ephemeral=True, - ) - return - if await GroupMadeMember.objects.filter( - hashed_group_member_id=GroupMadeMember.hash_group_member_id( - group_member_id, self.bot.group_member_id_type - ) - ).aexists(): - await ctx.followup.send( - content=( - ":information_source: No changes made. This student ID has already " - f"been used. Please contact a { - await self.bot.get_mention_string(self.bot.committee_role) - } member if this is an error. :information_source:" - ), - ephemeral=True, - ) - return + async with ctx.typing(): + status, message = await self.perform_make_member( + user=ctx.user, raw_group_member_id=raw_group_member_id + ) - if not await is_id_a_community_group_member(member_id=group_member_id): - await self.command_send_error( - ctx, - message=( - f"You must be a member of {self.bot.group_full_name} " - "to use this command.\n" - f"The provided {_GROUP_MEMBER_ID_ARGUMENT_NAME} must match " - f"the {self.bot.group_member_id_type} ID " - f"that you purchased your {self.bot.group_short_name} membership with." - ), - ) + if status == MakeMemberStatus.ERROR: + await self.command_send_error(ctx=ctx, message=message) return - # NOTE: The "Member" role must be added to the user **before** the "Guest" role to ensure that the welcome message does not include the suggestion to purchase membership - await interaction_member.add_roles( - member_role, reason=f'{ctx.user} used TeX Bot slash-command: "/make-member"' - ) - - try: - await GroupMadeMember.objects.acreate(group_member_id=raw_group_member_id) # type: ignore[misc] - except ValidationError as create_group_made_member_error: - error_is_already_exists: bool = ( - "hashed_group_member_id" in create_group_made_member_error.message_dict - and any( - "already exists" in error - for error in create_group_made_member_error.message_dict[ - "hashed_group_member_id" - ] - ) - ) - if not error_is_already_exists: - raise - - await ctx.followup.send(content="Successfully made you a member!", ephemeral=True) - - try: - guest_role: discord.Role = await self.bot.guest_role - except GuestRoleDoesNotExistError: - logger.warning( - '"/make-member" command used but the "Guest" role does not exist. ' - 'Some user\'s may now have the "Member" role without the "Guest" role. ' - 'Use the "/ensure-members-inducted" command to fix this issue.' - ) - else: - if guest_role not in interaction_member.roles: - await interaction_member.add_roles( - guest_role, - reason=f'{ctx.user} used TeX Bot slash-command: "/make-member"', - ) - applicant_role: discord.Role | None - try: - applicant_role = await ctx.bot.applicant_role - except ApplicantRoleDoesNotExistError: - applicant_role = None - - if applicant_role and applicant_role in interaction_member.roles: - await interaction_member.remove_roles( - applicant_role, - reason=f'{ctx.user} used TeX Bot slash-command: "/make-member"', - ) + await ctx.followup.send(content=message, ephemeral=True) class MemberCountCommandCog(TeXBotBaseCog): @@ -225,3 +247,132 @@ async def member_count(self, ctx: "TeXBotApplicationContext") -> None: f"{await fetch_community_group_members_count()} members! :tada:" ) ) + + +class MakeMemberModal(Modal): + """A Modal containing an input field for users to enter their student ID.""" + + @override + def __init__(self, bot: "TeXBot") -> None: + super().__init__(title="Make Member Modal") + self.bot: TeXBot = bot + self.add_item( + discord.ui.InputText( + label="Student ID", + min_length=7, + max_length=7, + required=True, + placeholder="1234567", + ) + ) + + @override + async def callback(self, interaction: discord.Interaction) -> None: + raw_student_id: str | None = self.children[0].value + if not raw_student_id: + await interaction.response.send_message( + content="Invalid Student ID.", ephemeral=True + ) + return + + if not interaction.user: + await interaction.response.send_message( + content="Something went wrong, contact a committee member if this persists.", + ephemeral=True, + ) + logger.debug( + "Interaction user was unexpectedly None in MakeMemberModal. Interaction: %s", + interaction.data, + ) + return + + await interaction.response.defer(ephemeral=True) + + cog: discord.Cog | None = self.bot.get_cog("MakeMemberCommandCog") + if not cog or not isinstance(cog, MakeMemberCommandCog): + await interaction.followup.send( + content="Something went wrong, contact a committee member if this persists.", + ephemeral=True, + ) + logger.error( + "MakeMemberModal could not find MakeMemberCommandCog in bot's cogs. " + "Make sure MakeMemberCommandCog is loaded. " + "Current cogs: %s", + list(self.bot.cogs.keys()), + ) + return + + _, message = await cog.perform_make_member( + user=interaction.user, raw_group_member_id=raw_student_id + ) + + await interaction.followup.send(content=message, ephemeral=True) + + +class OpenMemberVerificationModalView(View): + """A View containing a button to open a new member verification modal.""" + + @override + def __init__(self, bot: "TeXBot") -> None: + super().__init__(timeout=None) + self.bot: TeXBot = bot + + @discord.ui.button( + label="Verify", style=discord.ButtonStyle.primary, custom_id="verify_new_member" + ) + async def verify_new_member_button_callback( # type: ignore[misc] + self, _: discord.Button, interaction: discord.Interaction + ) -> None: + await interaction.response.send_modal(MakeMemberModal(bot=self.bot)) + + +class MakeMemberModalCommandCog(MakeMemberBaseCog): + """Cog class that defines the "/send-make-member-modal" command and its call-back method.""" # noqa: W505, E501 + + @TeXBotBaseCog.listener() + async def on_ready(self) -> None: + """Add OpenMemberVerifyModalView to the bot's list of permanent views.""" + self.bot.add_view(OpenMemberVerificationModalView(bot=self.bot)) + + async def _open_make_new_member_modal( + self, + button_callback_channel: discord.TextChannel, + ) -> None: + await button_callback_channel.send( + content="Click below to verify membership!", + view=OpenMemberVerificationModalView(bot=self.bot), + ) + + @discord.slash_command( + name="send-make-member-modal", + description=( + "Sends a message with a button that allows users to open the make member modal." + ), + ) + @CommandChecks.check_interaction_user_has_committee_role + @CommandChecks.check_interaction_user_in_main_guild + async def send_make_member_modal( + self, + ctx: "TeXBotApplicationContext", + ) -> None: + """ + Definition & callback response of the "send-make-member-modal" command. + + The "send-make-member-modal" command sends a message with a button that allows users + to open the make member modal + """ + if not isinstance(ctx.channel, discord.TextChannel): + await ctx.respond( + content="This command can only be used in text channels.", + ephemeral=True, + ) + return + + await self._open_make_new_member_modal( + button_callback_channel=ctx.channel, + ) + + await ctx.respond( + content="The make member modal message has been sent in this channel.", + ephemeral=True, + )