From 1a4f883746f45c49ea19119bb8c71c7b38b442b0 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:54:03 +0100 Subject: [PATCH 1/8] Implement member colour change command --- cogs/__init__.py | 3 ++ cogs/colour_selector.py | 108 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 cogs/colour_selector.py 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..c0149c6a --- /dev/null +++ b/cogs/colour_selector.py @@ -0,0 +1,108 @@ +"""Contains cog classes for the colour selector command.""" + +import logging +from typing import TYPE_CHECKING + +import discord + +from exceptions import DiscordMemberNotInMainGuildError +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]]" = { + "green", + "blue", + "red", + "yellow", + "orange", + "purple", + "pink", +} + + +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.""" + return { + discord.OptionChoice( + name=role.name, + value=role.id, + ) + for role in ctx.bot.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 + 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 + + role_to_add: discord.Role | None = discord.utils.get( + main_guild.roles, id=int(role_id_str) + ) + + if not role_to_add or not interaction_member: + await ctx.respond( + "The role you selected does not exist. Please use the autocomplete.", + ephemeral=True, + ) + return + + if isinstance(interaction_member, discord.User): + try: + fetched_member: discord.Member = await ctx.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 + + for role in interaction_member.roles: + if role.name.lower() in colour_role_names: + await interaction_member.remove_roles(role) + + await interaction_member.add_roles( + role_to_add, + reason=f"{interaction_member.global_name} used TeX-Bot /member_colour_select.", + ) From b27aff898f02e0afb8feac876f6653d20ce0a296 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:55:37 +0100 Subject: [PATCH 2/8] Fix errors --- cogs/colour_selector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py index c0149c6a..8c5b3905 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -52,11 +52,11 @@ async def autocomplete_colour_roles( } @discord.slash_command( - name="member_colour_select", + name="member-colour-select", description="Select a colour role for yourself.", ) @discord.option( - name="colour_role", + name="colour-role", description="The colour role you want to select.", autocomplete=discord.utils.basic_autocomplete(autocomplete_colour_roles), input_type=str, From 95237337e917cf7eb4291b1d3d36d44f6b2b9bda Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:04:39 +0100 Subject: [PATCH 3/8] Add util function --- utils/command_checks.py | 20 ++++++++++++++++++++ utils/tex_bot.py | 4 ++++ 2 files changed, 24 insertions(+) diff --git a/utils/command_checks.py b/utils/command_checks.py index 8977e40a..9412e0af 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.""" 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. From 8f146667a3e33e06cdc197699c3324ac560e9681 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:09:32 +0100 Subject: [PATCH 4/8] Drop in real role names --- cogs/colour_selector.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py index 8c5b3905..19b04df9 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -23,14 +23,14 @@ logger: "Final[Logger]" = logging.getLogger("TeX-Bot") -colour_role_names: "Final[AbstractSet[str]]" = { - "green", - "blue", - "red", - "yellow", +COLOUR_ROLE_NAMES: "Final[AbstractSet[str]]" = { + "og-green", + "pink", "orange", "purple", - "pink", + "new-green", + "yellow", + "red", } @@ -48,7 +48,7 @@ async def autocomplete_colour_roles( value=role.id, ) for role in ctx.bot.main_guild.roles - if role.name.lower() in colour_role_names + if role.name.lower() in COLOUR_ROLE_NAMES } @discord.slash_command( @@ -99,7 +99,7 @@ async def member_colour_select( interaction_member = fetched_member for role in interaction_member.roles: - if role.name.lower() in colour_role_names: + if role.name.lower() in COLOUR_ROLE_NAMES: await interaction_member.remove_roles(role) await interaction_member.add_roles( From 23ddee81658682074dc6e428b78e56f60ef48bcd Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:52:50 +0100 Subject: [PATCH 5/8] Fixes --- cogs/colour_selector.py | 47 ++++++++++++++++++++++++++++++++++------- utils/command_checks.py | 5 +++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py index 19b04df9..9759d626 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -5,7 +5,7 @@ import discord -from exceptions import DiscordMemberNotInMainGuildError +from exceptions import DiscordMemberNotInMainGuildError, GuildDoesNotExistError from utils import CommandChecks, TeXBotBaseCog if TYPE_CHECKING: @@ -23,7 +23,7 @@ logger: "Final[Logger]" = logging.getLogger("TeX-Bot") -COLOUR_ROLE_NAMES: "Final[AbstractSet[str]]" = { +COLOUR_ROLE_NAMES: "Final[AbstractSet[str]]" = { # TODO: Make this a config option in the future "og-green", "pink", "orange", @@ -42,12 +42,17 @@ 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=role.id, + value=str(role.id), ) - for role in ctx.bot.main_guild.roles + for role in main_guild.roles if role.name.lower() in COLOUR_ROLE_NAMES } @@ -64,6 +69,7 @@ async def autocomplete_colour_roles( 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: @@ -72,20 +78,43 @@ async def member_colour_select( main_guild: discord.Guild = ctx.bot.main_guild interaction_member: discord.Member | discord.User | None = ctx.interaction.user + if not interaction_member: + await self.command_send_error( + ctx=ctx, + message="Interaction user was None for member-colour-select command execution." + ) + return + + try: + role_id_int = int(role_id_str) + except ValueError: + await self.command_send_error( + ctx=ctx, + message="Value entered was not a valid role ID." + ) + return + role_to_add: discord.Role | None = discord.utils.get( - main_guild.roles, id=int(role_id_str) + main_guild.roles, id=role_id_int ) - if not role_to_add or not interaction_member: + 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 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 ctx.bot.get_main_guild_member( + fetched_member: discord.Member = await self.bot.get_main_guild_member( interaction_member ) except DiscordMemberNotInMainGuildError: @@ -104,5 +133,7 @@ async def member_colour_select( await interaction_member.add_roles( role_to_add, - reason=f"{interaction_member.global_name} used TeX-Bot /member_colour_select.", + reason=f"{interaction_member} used TeX-Bot /member_colour_select.", ) + + await ctx.respond(f"Successfully gave you the {role_to_add.name} colour role!") diff --git a/utils/command_checks.py b/utils/command_checks.py index 9412e0af..2072ec22 100644 --- a/utils/command_checks.py +++ b/utils/command_checks.py @@ -94,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] From fba38c68f4729607852554740eba8528794bd764 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:58:05 +0100 Subject: [PATCH 6/8] Improvements --- cogs/colour_selector.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py index 9759d626..4e94f54c 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -127,9 +127,21 @@ async def member_colour_select( interaction_member = fetched_member - for role in interaction_member.roles: - if role.name.lower() in COLOUR_ROLE_NAMES: - await interaction_member.remove_roles(role) + 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 not roles_to_remove: + await ctx.respond( + f"You already have the {role_to_add.name} colour role!", + ephemeral=True, + ) + return + + 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, From fa8f7e5f9a40127376202ad01574b7dfc63e76c0 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:02:46 +0100 Subject: [PATCH 7/8] Fixes --- cogs/colour_selector.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py index 4e94f54c..29e513a6 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -23,7 +23,7 @@ logger: "Final[Logger]" = logging.getLogger("TeX-Bot") -COLOUR_ROLE_NAMES: "Final[AbstractSet[str]]" = { # TODO: Make this a config option in the future +COLOUR_ROLE_NAMES: "Final[AbstractSet[str]]" = { # TODO: Make this a config option in the future # noqa: FIX002 "og-green", "pink", "orange", @@ -81,7 +81,7 @@ async def member_colour_select( if not interaction_member: await self.command_send_error( ctx=ctx, - message="Interaction user was None for member-colour-select command execution." + message="Interaction user was None for member-colour-select command run.", ) return @@ -89,14 +89,11 @@ async def member_colour_select( role_id_int = int(role_id_str) except ValueError: await self.command_send_error( - ctx=ctx, - message="Value entered was not a valid role ID." + ctx=ctx, message="Value entered was not a valid role ID." ) return - role_to_add: discord.Role | None = discord.utils.get( - main_guild.roles, id=role_id_int - ) + role_to_add: discord.Role | None = discord.utils.get(main_guild.roles, id=role_id_int) if not role_to_add: await ctx.respond( @@ -107,8 +104,7 @@ async def member_colour_select( if role_to_add.name not in COLOUR_ROLE_NAMES: await ctx.respond( - f"{role_to_add.name} is not a valid colour role. " - "Please use the autocomplete." + f"{role_to_add.name} is not a valid colour role. Please use the autocomplete." ) return @@ -141,7 +137,10 @@ async def member_colour_select( ) return - await interaction_member.remove_roles(*roles_to_remove, reason=f"{interaction_member} used TeX-Bot /member_colour_select.") + 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, From 38053a9a8edb64d82d20066cc4396ae5cd73c011 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:23:44 +0100 Subject: [PATCH 8/8] More fixes --- cogs/colour_selector.py | 116 +++++++++++++++++++++------------------- cogs/command_error.py | 6 +++ 2 files changed, 67 insertions(+), 55 deletions(-) diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py index 29e513a6..a5d23a19 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -78,73 +78,79 @@ async def member_colour_select( main_guild: discord.Guild = ctx.bot.main_guild interaction_member: discord.Member | discord.User | None = ctx.interaction.user - 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 self.command_send_error( - ctx=ctx, message="Value entered was not a valid role ID." - ) - return + await ctx.defer(ephemeral=True) - role_to_add: discord.Role | None = discord.utils.get(main_guild.roles, id=role_id_int) + 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 - if not role_to_add: - await ctx.respond( - "The role you selected does not exist. Please use the autocomplete.", - ephemeral=True, - ) - 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 - if role_to_add.name not in COLOUR_ROLE_NAMES: - await ctx.respond( - f"{role_to_add.name} is not a valid colour role. Please use the autocomplete." + role_to_add: discord.Role | None = discord.utils.get( + main_guild.roles, id=role_id_int ) - return - if isinstance(interaction_member, discord.User): - try: - fetched_member: discord.Member = await self.bot.get_main_guild_member( - interaction_member - ) - except DiscordMemberNotInMainGuildError: + if not role_to_add: await ctx.respond( - "You are not a member of the main guild. " - "Please join the main guild to use this command.", + "The role you selected does not exist. Please use the autocomplete.", ephemeral=True, ) return - interaction_member = fetched_member + 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 - roles_to_remove: list[discord.Role] = [ - role for role in interaction_member.roles if role.name.lower() in COLOUR_ROLE_NAMES - ] + 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.", + ) - if role_to_add in roles_to_remove: - roles_to_remove.remove(role_to_add) + await interaction_member.add_roles( + role_to_add, + reason=f"{interaction_member} used TeX-Bot /member-colour-select.", + ) - if not roles_to_remove: await ctx.respond( - f"You already have the {role_to_add.name} colour role!", - ephemeral=True, + f"Successfully gave you the {role_to_add.name} colour role!", ephemeral=True ) - return - - 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!") 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