diff --git a/cogs/__init__.py b/cogs/__init__.py index 0c74de61..8c79dda3 100644 --- a/cogs/__init__.py +++ b/cogs/__init__.py @@ -18,6 +18,7 @@ CheckSUPlatformAuthorisationCommandCog, CheckSUPlatformAuthorisationTaskCog, ) +from .colour_selector import MemberColourSelectorCommandCog from .command_error import CommandErrorCog from .committee_actions_tracking import ( CommitteeActionsTrackingContextCommandCog, @@ -76,6 +77,7 @@ "MakeApplicantSlashCommandCog", "MakeMemberCommandCog", "ManualModerationCog", + "MemberColourSelectorCommandCog", "MemberCountCommandCog", "PingCommandCog", "RemindMeCommandCog", @@ -117,6 +119,7 @@ def setup(bot: "TeXBot") -> None: MakeApplicantSlashCommandCog, MakeMemberCommandCog, ManualModerationCog, + MemberColourSelectorCommandCog, MemberCountCommandCog, PingCommandCog, RemindMeCommandCog, diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py new file mode 100644 index 00000000..a5d23a19 --- /dev/null +++ b/cogs/colour_selector.py @@ -0,0 +1,156 @@ +"""Contains cog classes for the colour selector command.""" + +import logging +from typing import TYPE_CHECKING + +import discord + +from exceptions import DiscordMemberNotInMainGuildError, GuildDoesNotExistError +from utils import CommandChecks, TeXBotBaseCog + +if TYPE_CHECKING: + from collections.abc import Sequence + from collections.abc import Set as AbstractSet + from logging import Logger + from typing import Final + + from utils import TeXBotApplicationContext, TeXBotAutocompleteContext + + +__all__: "Sequence[str]" = ("MemberColourSelectorCommandCog",) + + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + +COLOUR_ROLE_NAMES: "Final[AbstractSet[str]]" = { # TODO: Make this a config option in the future # noqa: FIX002 + "og-green", + "pink", + "orange", + "purple", + "new-green", + "yellow", + "red", +} + + +class MemberColourSelectorCommandCog(TeXBotBaseCog): + """Cog class for the colour selector command.""" + + @staticmethod + async def autocomplete_colour_roles( + ctx: "TeXBotAutocompleteContext", + ) -> "AbstractSet[discord.OptionChoice] | AbstractSet[str]": + """Autocomplete function for the colour roles option of the colour selector command.""" + try: + main_guild: discord.Guild = ctx.bot.main_guild + except GuildDoesNotExistError: + return set() + + return { + discord.OptionChoice( + name=role.name, + value=str(role.id), + ) + for role in main_guild.roles + if role.name.lower() in COLOUR_ROLE_NAMES + } + + @discord.slash_command( + name="member-colour-select", + description="Select a colour role for yourself.", + ) + @discord.option( + name="colour-role", + description="The colour role you want to select.", + autocomplete=discord.utils.basic_autocomplete(autocomplete_colour_roles), + input_type=str, + required=True, + parameter_name="role_id_str", + ) + @CommandChecks.check_interaction_user_in_main_guild + @CommandChecks.check_interaction_user_has_member_role + async def member_colour_select( + self, ctx: "TeXBotApplicationContext", role_id_str: str + ) -> None: + """Slash command for selecting a colour role for the user.""" + # 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 + main_guild: discord.Guild = ctx.bot.main_guild + interaction_member: discord.Member | discord.User | None = ctx.interaction.user + + await ctx.defer(ephemeral=True) + + async with ctx.typing(): + if not interaction_member: + await self.command_send_error( + ctx=ctx, + message="Interaction user was None for member-colour-select command run.", + ) + return + + try: + role_id_int = int(role_id_str) + except ValueError: + await ctx.respond( + "The role ID you provided is not a valid role ID. " + "Please use the autocomplete.", + ephemeral=True, + ) + return + + role_to_add: discord.Role | None = discord.utils.get( + main_guild.roles, id=role_id_int + ) + + if not role_to_add: + await ctx.respond( + "The role you selected does not exist. Please use the autocomplete.", + ephemeral=True, + ) + return + + if role_to_add.name.lower() not in COLOUR_ROLE_NAMES: + await ctx.respond( + f"{role_to_add.name} is not a valid colour role. " + "Please use the autocomplete." + ) + return + + if isinstance(interaction_member, discord.User): + try: + fetched_member: discord.Member = await self.bot.get_main_guild_member( + interaction_member + ) + except DiscordMemberNotInMainGuildError: + await ctx.respond( + "You are not a member of the main guild. " + "Please join the main guild to use this command.", + ephemeral=True, + ) + return + + interaction_member = fetched_member + + roles_to_remove: list[discord.Role] = [ + role + for role in interaction_member.roles + if role.name.lower() in COLOUR_ROLE_NAMES + ] + + if role_to_add in roles_to_remove: + roles_to_remove.remove(role_to_add) + + if roles_to_remove: + await interaction_member.remove_roles( + *roles_to_remove, + reason=f"{interaction_member} used TeX-Bot /member-colour-select.", + ) + + await interaction_member.add_roles( + role_to_add, + reason=f"{interaction_member} used TeX-Bot /member-colour-select.", + ) + + await ctx.respond( + f"Successfully gave you the {role_to_add.name} colour role!", ephemeral=True + ) diff --git a/cogs/command_error.py b/cogs/command_error.py index fcac8ee1..5248661c 100644 --- a/cogs/command_error.py +++ b/cogs/command_error.py @@ -62,6 +62,12 @@ async def on_application_command_error( "members can run this command." ) + elif CommandChecks.is_interaction_user_has_member_role_failure(error.checks[0]): # type: ignore[arg-type] + message = ( + f"Only {await self.bot.get_mention_string(self.bot.member_role)} " + "members can run this command." + ) + else: logging_message = error diff --git a/utils/command_checks.py b/utils/command_checks.py index 8977e40a..2072ec22 100644 --- a/utils/command_checks.py +++ b/utils/command_checks.py @@ -65,6 +65,26 @@ async def _check(ctx: "TeXBotApplicationContext") -> bool: ) )(func) + @staticmethod + def check_interaction_user_has_member_role[T: TeXBotBaseCog, **P]( + func: "Callable[Concatenate[T, P], Awaitable[None]]", + ) -> "Callable[Concatenate[T, P], Awaitable[None]]": + """ + Command check decorator to ensure the interaction user has the "Member" role. + + If this check does not pass, the decorated command will not be executed. + Instead, an error message will be sent to the user. + """ + + async def _check(ctx: "TeXBotApplicationContext") -> bool: + return await ctx.bot.check_user_has_member_role(ctx.user) + + return commands.check_any( + commands.check( + _check # type: ignore[arg-type] + ) + )(func) + @classmethod def is_interaction_user_in_main_guild_failure(cls, check: "CheckFailure") -> bool: """Whether the check failed due to the user not being in your Discord guild.""" @@ -74,3 +94,8 @@ def is_interaction_user_in_main_guild_failure(cls, check: "CheckFailure") -> boo def is_interaction_user_has_committee_role_failure(cls, check: "CheckFailure") -> bool: """Whether the check failed due to the user not having the committee role.""" return bool(check.__name__ == cls.check_interaction_user_has_committee_role.__name__) # type: ignore[attr-defined] + + @classmethod + def is_interaction_user_has_member_role_failure(cls, check: "CheckFailure") -> bool: + """Whether the check failed due to the user not having the Member role.""" + return bool(check.__name__ == cls.check_interaction_user_has_member_role.__name__) # type: ignore[attr-defined] diff --git a/utils/tex_bot.py b/utils/tex_bot.py index f86749fd..6830dca2 100644 --- a/utils/tex_bot.py +++ b/utils/tex_bot.py @@ -434,6 +434,10 @@ async def check_user_has_committee_role(self, user: discord.Member | discord.Use """Util method to validate whether the given user has the "Committee" role.""" return await self.committee_role in (await self.get_main_guild_member(user)).roles + async def check_user_has_member_role(self, user: discord.Member | discord.User) -> bool: + """Util method to validate whether the given user has the "Member" role.""" + return await self.member_role in (await self.get_main_guild_member(user)).roles + def set_main_guild(self, main_guild: discord.Guild) -> None: """ Set the main_guild value that TeX-Bot will reference in the future.