diff --git a/cogs/__init__.py b/cogs/__init__.py index 2a1699bbc..62e1e3d61 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, @@ -75,6 +76,7 @@ "MakeApplicantSlashCommandCog", "MakeMemberCommandCog", "ManualModerationCog", + "MemberColourSelectorCommandCog", "MemberCountCommandCog", "PingCommandCog", "SendGetRolesRemindersTaskCog", @@ -114,6 +116,7 @@ def setup(bot: TeXBot) -> None: MakeApplicantSlashCommandCog, MakeMemberCommandCog, ManualModerationCog, + MemberColourSelectorCommandCog, MemberCountCommandCog, PingCommandCog, SendGetRolesRemindersTaskCog, diff --git a/cogs/add_users_to_threads_and_channels.py b/cogs/add_users_to_threads_and_channels.py index 619b1faf1..bc9adf920 100644 --- a/cogs/add_users_to_threads_and_channels.py +++ b/cogs/add_users_to_threads_and_channels.py @@ -8,7 +8,11 @@ import discord from config import settings -from exceptions import GuestRoleDoesNotExistError, GuildDoesNotExistError +from exceptions import ( + GuestRoleDoesNotExistError, + GuildDoesNotExistError, + RoleNotFoundInMainGuildError, +) from utils import ( CommandChecks, TeXBotApplicationContext, # noqa: TC001 @@ -276,20 +280,20 @@ async def add_role_to_channel( ) return - main_guild: discord.Guild = ctx.bot.main_guild - + role_to_add: discord.Role try: - role_id: int = int(role_id_str) - except ValueError: - logger.debug("Role ID: %s is not a valid ID.", role_id_str) - await ctx.respond(content=f"The role: {role_id_str} is not valid.") + role_to_add = await ctx.bot.get_role_from_str_id(role_id_str) + except RoleNotFoundInMainGuildError: + await ctx.respond( + "The specified role could not be found in the main guild. " + "Please use the autocomplete.", + ephemeral=True, + ) return - - role_to_add: discord.Role | None = discord.utils.get(main_guild.roles, id=role_id) - - if role_to_add is None: - await self.command_send_error( - ctx, message=f"The role: <@{role_id}> is not valid or couldn't be found." + except ValueError: + await ctx.respond( + "The specified role ID is not a valid role ID. Please use the autocomplete.", + ephemeral=True, ) return diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py new file mode 100644 index 000000000..4f6c0a18e --- /dev/null +++ b/cogs/colour_selector.py @@ -0,0 +1,171 @@ +"""Contains cog classes for the colour selector command.""" + +import logging +from typing import TYPE_CHECKING + +import discord + +from exceptions import ( + GuildDoesNotExistError, + RoleNotFoundInMainGuildError, +) +from utils import CommandChecks, TeXBotBaseCog + +if TYPE_CHECKING: + from collections.abc import Collection, 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") + +# TODO: Make this a config option in the future # noqa: FIX002 +COLOUR_ROLE_NAMES: Final[AbstractSet[str]] = { + "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() + + roles: set[discord.Role] = { + role for role in main_guild.roles if role.name.lower() in COLOUR_ROLE_NAMES + } + + if not ctx.value or ctx.value.startswith("@"): + return { + discord.OptionChoice(name=f"@{role.name}", value=str(role.id)) + for role in roles + } + + return {discord.OptionChoice(name=role.name, value=str(role.id)) for role in roles} + + @discord.slash_command( + name="select-colour-role", 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="str_role_id", + ) + @CommandChecks.check_interaction_user_in_main_guild + @CommandChecks.check_interaction_user_has_member_role + async def select_colour_role( + self, ctx: TeXBotApplicationContext, str_role_id: str + ) -> None: + """ + Slash command for selecting a colour role for the user. + + Definition & callback response of the "select-colour-role" command. + The "select-colour-role" command assigns a colour role to the member that used + the command to allow them to change their display colour. + """ + await ctx.defer(ephemeral=True) + + async with ctx.typing(): + if not ctx.interaction.user: + await self.command_send_error( + ctx=ctx, + message=( + "Cannot assign colour role when interaction user was not available." + ), + ) + return + + role_to_add: discord.Role + try: + role_to_add = await ctx.bot.get_role_from_str_id(str_role_id) + except RoleNotFoundInMainGuildError: + await ctx.followup.send( + "The specified role could not be found in the main guild. " + "Please use the autocomplete.", + ephemeral=True, + ) + return + except ValueError: + await ctx.followup.send( + "The specified role ID is not a valid role ID. " + "Please use the autocomplete.", + ephemeral=True, + ) + return + + if role_to_add.name.lower() not in COLOUR_ROLE_NAMES: + await ctx.followup.send( + ":information_source: No changes made. " + f"{role_to_add.name} is not a valid colour role. " + ":information_source:" + ) + return + + interaction_member: discord.Member = await self.bot.get_main_guild_member( + ctx.interaction.user + ) + + roles_to_remove: Collection[discord.Role] = [ + role + for role in interaction_member.roles + if role.name.lower() in COLOUR_ROLE_NAMES and role != role_to_add + ] + + member_already_has_role_to_add: bool = role_to_add in interaction_member.roles + + if member_already_has_role_to_add and not roles_to_remove: + await ctx.followup.send( + ":information_source: No changes made. " + f"You already have the {role_to_add.name} colour role. " + ":information_source:", + ephemeral=True, + ) + return + + await interaction_member.remove_roles( + *roles_to_remove, + reason=( + f'{interaction_member} used TeX-Bot slash-command "/select-colour-role".' + ), + ) + + if member_already_has_role_to_add: + await ctx.followup.send( + ":information_source: No changes made. " + f"You already have the {role_to_add.name} colour role.", + ephemeral=True, + ) + return + + await interaction_member.add_roles( + role_to_add, + reason=( + f'{interaction_member} used TeX-Bot slash-command "/select-colour-role".' + ), + ) + + await ctx.followup.send( + 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 d6c5e55a0..f6f7d84ee 100644 --- a/cogs/command_error.py +++ b/cogs/command_error.py @@ -7,6 +7,7 @@ from discord import Forbidden from discord.ext.commands.errors import CheckAnyFailure +from config import settings from exceptions import GuildDoesNotExistError from exceptions.base import BaseErrorWithErrorCode from utils import CommandChecks, TeXBotBaseCog @@ -62,6 +63,13 @@ 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)}s " + "can run this command. Buy membership or run the 'make-member' command." + f"\n{settings['PURCHASE_MEMBERSHIP_URL']}" + ) + else: logging_message = error diff --git a/exceptions/__init__.py b/exceptions/__init__.py index 4448196eb..edcbcd1bb 100644 --- a/exceptions/__init__.py +++ b/exceptions/__init__.py @@ -18,7 +18,11 @@ RolesChannelDoesNotExistError, RulesChannelDoesNotExistError, ) -from .guild import DiscordMemberNotInMainGuildError, EveryoneRoleCouldNotBeRetrievedError +from .guild import ( + DiscordMemberNotInMainGuildError, + EveryoneRoleCouldNotBeRetrievedError, + RoleNotFoundInMainGuildError, +) from .messages import ( InvalidMessagesJSONFileError, MessagesJSONFileMissingKeyError, @@ -52,6 +56,7 @@ "NoAuditLogsStrikeTrackingError", "RestartRequiredDueToConfigChange", "RoleDoesNotExistError", + "RoleNotFoundInMainGuildError", "RolesChannelDoesNotExistError", "RulesChannelDoesNotExistError", "StrikeTrackingError", diff --git a/exceptions/guild.py b/exceptions/guild.py index ce66d95aa..1817caa6d 100644 --- a/exceptions/guild.py +++ b/exceptions/guild.py @@ -12,6 +12,7 @@ __all__: Sequence[str] = ( "DiscordMemberNotInMainGuildError", "EveryoneRoleCouldNotBeRetrievedError", + "RoleNotFoundInMainGuildError", ) @@ -31,6 +32,22 @@ def __init__(self, message: str | None = None, user_id: int | None = None) -> No super().__init__(message) +class RoleNotFoundInMainGuildError(BaseTeXBotError, ValueError): + """Exception class for when a role could not be found in the main guild.""" + + @classproperty + @override + def DEFAULT_MESSAGE(cls) -> str: + return "The specified role could not be found in the main guild." + + @override + def __init__(self, message: str | None = None, role_id: int | None = None) -> None: + """Initialise a ValueError exception for a non-existent role ID.""" + self.role_id: int | None = role_id + + super().__init__(message) + + class EveryoneRoleCouldNotBeRetrievedError(BaseErrorWithErrorCode, ValueError): """Exception class for when the "@everyone" role could not be retrieved.""" diff --git a/utils/command_checks.py b/utils/command_checks.py index 7379528be..be9097172 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.""" @@ -72,5 +92,10 @@ def is_interaction_user_in_main_guild_failure(cls, check: CheckFailure) -> bool: @classmethod 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.""" + """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 e6473aceb..4578abb1d 100644 --- a/utils/tex_bot.py +++ b/utils/tex_bot.py @@ -22,6 +22,7 @@ GuildDoesNotExistError, MemberRoleDoesNotExistError, RoleDoesNotExistError, + RoleNotFoundInMainGuildError, RolesChannelDoesNotExistError, RulesChannelDoesNotExistError, ) @@ -434,6 +435,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. @@ -492,6 +497,33 @@ async def get_member_from_str_id(self, str_member_id: str) -> discord.Member: return member + async def get_role_from_str_id(self, str_role_id: str) -> discord.Role: + """ + Retrieve a role of your group's Discord guild by its ID. + + Raises `ValueError` if the provided ID does not represent any role + of your group's Discord guild. + """ + str_role_id = re.sub(r"\A\s*(<@&)(.*)(?(1)>|)\s*\Z", r"\2", str_role_id) + + if not re.fullmatch(r"\A\d{17,20}\Z", str_role_id): + INVALID_ROLE_ID_MESSAGE: Final[str] = f"'{str_role_id}' is not a valid role ID." + raise ValueError(INVALID_ROLE_ID_MESSAGE) + + role: discord.Role | None = await self.main_guild.get_or_fetch( + discord.Role, int(str_role_id) + ) + + if role is None: + ROLE_NOT_FOUND_MESSAGE: Final[str] = ( + f"Role with ID '{str_role_id}' could not be found in the main guild." + ) + raise RoleNotFoundInMainGuildError( + message=ROLE_NOT_FOUND_MESSAGE, role_id=int(str_role_id) + ) + + return role + async def fetch_log_channel(self) -> discord.TextChannel: """ Retrieve the Discord log channel.