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 01/18] 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 0c74de61e..8c79dda38 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 000000000..c0149c6ab --- /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 02/18] 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 c0149c6ab..8c5b39053 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 03/18] 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 8977e40ad..9412e0afb 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 f86749fd6..6830dca2a 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 04/18] 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 8c5b39053..19b04df9a 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 05/18] 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 19b04df9a..9759d6266 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 9412e0afb..2072ec225 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 06/18] 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 9759d6266..4e94f54ca 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 07/18] 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 4e94f54ca..29e513a61 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 08/18] 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 29e513a61..a5d23a19d 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 fcac8ee12..5248661cb 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 From 0656dde9ab93b66c14fe952f136c131e8b1ec605 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:24:20 +0100 Subject: [PATCH 09/18] Run ruff format --- cogs/colour_selector.py | 12 ++++++------ utils/command_checks.py | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py index a5d23a19d..7aba0555f 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -17,13 +17,13 @@ from utils import TeXBotApplicationContext, TeXBotAutocompleteContext -__all__: "Sequence[str]" = ("MemberColourSelectorCommandCog",) +__all__: Sequence[str] = ("MemberColourSelectorCommandCog",) -logger: "Final[Logger]" = logging.getLogger("TeX-Bot") +logger: Final[Logger] = logging.getLogger("TeX-Bot") -COLOUR_ROLE_NAMES: "Final[AbstractSet[str]]" = { # TODO: Make this a config option in the future # noqa: FIX002 +COLOUR_ROLE_NAMES: Final[AbstractSet[str]] = { # TODO: Make this a config option in the future # noqa: FIX002 "og-green", "pink", "orange", @@ -39,8 +39,8 @@ class MemberColourSelectorCommandCog(TeXBotBaseCog): @staticmethod async def autocomplete_colour_roles( - ctx: "TeXBotAutocompleteContext", - ) -> "AbstractSet[discord.OptionChoice] | AbstractSet[str]": + 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 @@ -71,7 +71,7 @@ async def autocomplete_colour_roles( @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 + 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 diff --git a/utils/command_checks.py b/utils/command_checks.py index ed498db2b..ebfdeec0b 100644 --- a/utils/command_checks.py +++ b/utils/command_checks.py @@ -67,8 +67,8 @@ async def _check(ctx: TeXBotApplicationContext) -> bool: @staticmethod def check_interaction_user_has_member_role[T: TeXBotBaseCog, **P]( - func: "Callable[Concatenate[T, P], Awaitable[None]]", - ) -> "Callable[Concatenate[T, P], Awaitable[None]]": + 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. @@ -76,7 +76,7 @@ def check_interaction_user_has_member_role[T: TeXBotBaseCog, **P]( Instead, an error message will be sent to the user. """ - async def _check(ctx: "TeXBotApplicationContext") -> bool: + async def _check(ctx: TeXBotApplicationContext) -> bool: return await ctx.bot.check_user_has_member_role(ctx.user) return commands.check_any( @@ -96,6 +96,6 @@ def is_interaction_user_has_committee_role_failure(cls, check: CheckFailure) -> 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: + 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 161c0372f14d3e5721a65f906356756e2d44fca3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:26:51 +0000 Subject: [PATCH 10/18] [autofix.ci] apply automated fixes --- cogs/colour_selector.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py index 7aba0555f..7cf500055 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -23,7 +23,9 @@ logger: Final[Logger] = logging.getLogger("TeX-Bot") -COLOUR_ROLE_NAMES: Final[AbstractSet[str]] = { # TODO: Make this a config option in the future # noqa: FIX002 +COLOUR_ROLE_NAMES: Final[ + AbstractSet[str] +] = { # TODO: Make this a config option in the future # noqa: FIX002 "og-green", "pink", "orange", From 1b456e405188c43bd4e093c9791a3da3ebc4814d Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:43:56 +0100 Subject: [PATCH 11/18] Refactor role fetching --- cogs/add_users_to_threads_and_channels.py | 30 ++++++++++++---------- cogs/colour_selector.py | 31 ++++++++++++++--------- exceptions/__init__.py | 7 ++++- exceptions/guild.py | 17 +++++++++++++ utils/tex_bot.py | 28 ++++++++++++++++++++ 5 files changed, 87 insertions(+), 26 deletions(-) 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 index 7cf500055..792b35d2e 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -5,7 +5,11 @@ import discord -from exceptions import DiscordMemberNotInMainGuildError, GuildDoesNotExistError +from exceptions import ( + DiscordMemberNotInMainGuildError, + GuildDoesNotExistError, + RoleNotFoundInMainGuildError, +) from utils import CommandChecks, TeXBotBaseCog if TYPE_CHECKING: @@ -75,7 +79,13 @@ async def autocomplete_colour_roles( async def member_colour_select( self, ctx: TeXBotApplicationContext, role_id_str: str ) -> None: - """Slash command for selecting a colour role for the user.""" + """ + Slash command for selecting a colour role for the user. + + Definition & callback response of the "member_colour_select" command. + The "member_colour_select" command assigns a colour role to the member that used + the command to allow them to change their display colour. + """ # 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 @@ -90,23 +100,20 @@ async def member_colour_select( ) return + role_to_add: discord.Role try: - role_id_int = int(role_id_str) - except ValueError: + role_to_add = await ctx.bot.get_role_from_str_id(role_id_str) + except RoleNotFoundInMainGuildError: await ctx.respond( - "The role ID you provided is not a valid role ID. " + "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_int - ) - - if not role_to_add: + except ValueError: await ctx.respond( - "The role you selected does not exist. Please use the autocomplete.", + "The specified role ID is not a valid role ID. " + "Please use the autocomplete.", ephemeral=True, ) return 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/tex_bot.py b/utils/tex_bot.py index 940ed46da..833c91739 100644 --- a/utils/tex_bot.py +++ b/utils/tex_bot.py @@ -24,6 +24,7 @@ RoleDoesNotExistError, RolesChannelDoesNotExistError, RulesChannelDoesNotExistError, + RoleNotFoundInMainGuildError, ) if TYPE_CHECKING: @@ -496,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(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. From ec53b4a6605ac37168e63361f8d736236f1148f8 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:59:01 +0100 Subject: [PATCH 12/18] Other fixes --- cogs/colour_selector.py | 28 +++++++++++++--------------- cogs/command_error.py | 4 ++-- utils/command_checks.py | 4 ++-- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py index 792b35d2e..1ef0d342a 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -5,6 +5,7 @@ import discord +from config import settings from exceptions import ( DiscordMemberNotInMainGuildError, GuildDoesNotExistError, @@ -86,17 +87,13 @@ async def member_colour_select( The "member_colour_select" command assigns a colour role to the member that used the command to allow them to change their display colour. """ - # 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: + if not ctx.interaction.user: await self.command_send_error( ctx=ctx, - message="Interaction user was None for member-colour-select command run.", + message="Cannot assign colour role when interaction user was not available.", ) return @@ -120,26 +117,27 @@ async def member_colour_select( if role_to_add.name.lower() not in COLOUR_ROLE_NAMES: await ctx.respond( + ":information_source: No changes made. " f"{role_to_add.name} is not a valid colour role. " - "Please use the autocomplete." + ":information_source:" ) return - if isinstance(interaction_member, discord.User): + interaction_member: discord.Member + if not isinstance(ctx.interaction.user, discord.Member): try: - fetched_member: discord.Member = await self.bot.get_main_guild_member( - interaction_member + interaction_member = await self.bot.get_main_guild_member( + ctx.interaction.user ) except DiscordMemberNotInMainGuildError: await ctx.respond( "You are not a member of the main guild. " - "Please join the main guild to use this command.", + "Please join the main guild to use this command." + f"\n{settings["PURCHASE_MEMBERSHIP_URL"]}", ephemeral=True, ) return - interaction_member = fetched_member - roles_to_remove: list[discord.Role] = [ role for role in interaction_member.roles @@ -152,12 +150,12 @@ async def member_colour_select( if roles_to_remove: await interaction_member.remove_roles( *roles_to_remove, - reason=f"{interaction_member} used TeX-Bot /member-colour-select.", + reason=f'{interaction_member} used TeX-Bot slash-command "/select-colour-role".', ) await interaction_member.add_roles( role_to_add, - reason=f"{interaction_member} used TeX-Bot /member-colour-select.", + reason=f'{interaction_member} used TeX-Bot slash-command "/select-colour-role".' ) await ctx.respond( diff --git a/cogs/command_error.py b/cogs/command_error.py index 7ac56ba36..7fa4bedf4 100644 --- a/cogs/command_error.py +++ b/cogs/command_error.py @@ -58,8 +58,8 @@ async def on_application_command_error( elif CommandChecks.is_interaction_user_has_committee_role_failure(error.checks[0]): # type: ignore[arg-type] message = ( - f"Only {await self.bot.get_mention_string(self.bot.committee_role)} " - "members can run this command." + f"Only {await self.bot.get_mention_string(self.bot.committee_role)}s " + "can run this command." ) elif CommandChecks.is_interaction_user_has_member_role_failure(error.checks[0]): # type: ignore[arg-type] diff --git a/utils/command_checks.py b/utils/command_checks.py index ebfdeec0b..be9097172 100644 --- a/utils/command_checks.py +++ b/utils/command_checks.py @@ -92,10 +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.""" + """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 abca8e5b45e3920e2b260f6b00bdba73693b0944 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:01:18 +0100 Subject: [PATCH 13/18] Formatting --- cogs/colour_selector.py | 16 ++++++++++++---- utils/tex_bot.py | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py index 1ef0d342a..834234e73 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -93,7 +93,10 @@ async def member_colour_select( if not ctx.interaction.user: await self.command_send_error( ctx=ctx, - message="Cannot assign colour role when interaction user was not available.", + message=( + "Cannot assign colour role when " + "interaction user was not available." + ), ) return @@ -133,7 +136,7 @@ async def member_colour_select( await ctx.respond( "You are not a member of the main guild. " "Please join the main guild to use this command." - f"\n{settings["PURCHASE_MEMBERSHIP_URL"]}", + f"\n{settings['PURCHASE_MEMBERSHIP_URL']}", ephemeral=True, ) return @@ -150,12 +153,17 @@ async def member_colour_select( if roles_to_remove: await interaction_member.remove_roles( *roles_to_remove, - reason=f'{interaction_member} used TeX-Bot slash-command "/select-colour-role".', + reason=( + f'{interaction_member} used TeX-Bot ' + f'slash-command "/select-colour-role".' + ), ) await interaction_member.add_roles( role_to_add, - reason=f'{interaction_member} used TeX-Bot slash-command "/select-colour-role".' + reason=( + f'{interaction_member} used TeX-Bot slash-command "/select-colour-role".' + ), ) await ctx.respond( diff --git a/utils/tex_bot.py b/utils/tex_bot.py index 833c91739..62e9f55c0 100644 --- a/utils/tex_bot.py +++ b/utils/tex_bot.py @@ -22,9 +22,9 @@ GuildDoesNotExistError, MemberRoleDoesNotExistError, RoleDoesNotExistError, + RoleNotFoundInMainGuildError, RolesChannelDoesNotExistError, RulesChannelDoesNotExistError, - RoleNotFoundInMainGuildError, ) if TYPE_CHECKING: From 4b8c11a02932f863ab007b4d091ca18e0ce6035a Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:17:00 +0100 Subject: [PATCH 14/18] Fix --- cogs/colour_selector.py | 25 +++++++------------------ cogs/command_error.py | 10 ++++++---- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py index 834234e73..c210bc86b 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -64,7 +64,7 @@ async def autocomplete_colour_roles( } @discord.slash_command( - name="member-colour-select", + name="select-colour-role", description="Select a colour role for yourself.", ) @discord.option( @@ -77,14 +77,14 @@ async def autocomplete_colour_roles( ) @CommandChecks.check_interaction_user_in_main_guild @CommandChecks.check_interaction_user_has_member_role - async def member_colour_select( + async def select_colour_role( self, ctx: TeXBotApplicationContext, role_id_str: str ) -> None: """ Slash command for selecting a colour role for the user. - Definition & callback response of the "member_colour_select" command. - The "member_colour_select" command assigns a colour role to the member that used + 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) @@ -126,20 +126,9 @@ async def member_colour_select( ) return - interaction_member: discord.Member - if not isinstance(ctx.interaction.user, discord.Member): - try: - interaction_member = await self.bot.get_main_guild_member( - ctx.interaction.user - ) - except DiscordMemberNotInMainGuildError: - await ctx.respond( - "You are not a member of the main guild. " - "Please join the main guild to use this command." - f"\n{settings['PURCHASE_MEMBERSHIP_URL']}", - ephemeral=True, - ) - return + interaction_member: discord.Member = await self.bot.get_main_guild_member( + ctx.interaction.user + ) roles_to_remove: list[discord.Role] = [ role diff --git a/cogs/command_error.py b/cogs/command_error.py index 7fa4bedf4..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 @@ -58,14 +59,15 @@ async def on_application_command_error( elif CommandChecks.is_interaction_user_has_committee_role_failure(error.checks[0]): # type: ignore[arg-type] message = ( - f"Only {await self.bot.get_mention_string(self.bot.committee_role)}s " - "can run this command." + f"Only {await self.bot.get_mention_string(self.bot.committee_role)} " + "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." + 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: From 62adb7e5db272fe8f7f93c99945f6bb0ca1aabce Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:22:19 +0100 Subject: [PATCH 15/18] More fixes --- cogs/colour_selector.py | 37 ++++++++++++++++++++----------------- utils/tex_bot.py | 2 +- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py index c210bc86b..950ead6ee 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -5,9 +5,7 @@ import discord -from config import settings from exceptions import ( - DiscordMemberNotInMainGuildError, GuildDoesNotExistError, RoleNotFoundInMainGuildError, ) @@ -54,18 +52,23 @@ async def autocomplete_colour_roles( 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 + 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.", + description="Select a colour role for yourself." ) @discord.option( name="colour-role", @@ -73,12 +76,12 @@ async def autocomplete_colour_roles( autocomplete=discord.utils.basic_autocomplete(autocomplete_colour_roles), input_type=str, required=True, - parameter_name="role_id_str", + 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, role_id_str: str + self, ctx: TeXBotApplicationContext, str_role_id: str ) -> None: """ Slash command for selecting a colour role for the user. @@ -102,16 +105,16 @@ async def select_colour_role( role_to_add: discord.Role try: - role_to_add = await ctx.bot.get_role_from_str_id(role_id_str) + role_to_add = await ctx.bot.get_role_from_str_id(str_role_id) except RoleNotFoundInMainGuildError: - await ctx.respond( + 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.respond( + await ctx.followup.send( "The specified role ID is not a valid role ID. " "Please use the autocomplete.", ephemeral=True, @@ -119,7 +122,7 @@ async def select_colour_role( return if role_to_add.name.lower() not in COLOUR_ROLE_NAMES: - await ctx.respond( + await ctx.followup.send( ":information_source: No changes made. " f"{role_to_add.name} is not a valid colour role. " ":information_source:" @@ -155,6 +158,6 @@ async def select_colour_role( ), ) - await ctx.respond( + await ctx.followup.send( f"Successfully gave you the {role_to_add.name} colour role!", ephemeral=True ) diff --git a/utils/tex_bot.py b/utils/tex_bot.py index 62e9f55c0..4578abb1d 100644 --- a/utils/tex_bot.py +++ b/utils/tex_bot.py @@ -504,7 +504,7 @@ async def get_role_from_str_id(self, str_role_id: str) -> discord.Role: 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(str_role_id)) + 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." From 144b333d983dcd41627bd6598439481ea8ac40c7 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:22:34 +0100 Subject: [PATCH 16/18] Reformat --- cogs/colour_selector.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py index 950ead6ee..9e879899b 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -58,17 +58,14 @@ async def autocomplete_colour_roles( if not ctx.value or ctx.value.startswith("@"): return { - discord.OptionChoice( - name=f"@{role.name}", - value=str(role.id) - ) for role in roles + 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." + name="select-colour-role", description="Select a colour role for yourself." ) @discord.option( name="colour-role", @@ -97,8 +94,7 @@ async def select_colour_role( await self.command_send_error( ctx=ctx, message=( - "Cannot assign colour role when " - "interaction user was not available." + "Cannot assign colour role when interaction user was not available." ), ) return @@ -146,7 +142,7 @@ async def select_colour_role( await interaction_member.remove_roles( *roles_to_remove, reason=( - f'{interaction_member} used TeX-Bot ' + f"{interaction_member} used TeX-Bot " f'slash-command "/select-colour-role".' ), ) From 5c41c771b8632329b42bf200c1825388f75084ea Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:23:09 +0100 Subject: [PATCH 17/18] Move TODO comment --- 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 9e879899b..467fbd646 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -25,10 +25,10 @@ logger: Final[Logger] = logging.getLogger("TeX-Bot") - +# TODO: Make this a config option in the future # noqa: FIX002 COLOUR_ROLE_NAMES: Final[ AbstractSet[str] -] = { # TODO: Make this a config option in the future # noqa: FIX002 +] = { "og-green", "pink", "orange", From 5a6c87a7ee6fbf17ecca6954dd4a0d800c3ae4e1 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:34:53 +0100 Subject: [PATCH 18/18] Improve removing logic --- cogs/colour_selector.py | 42 ++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/cogs/colour_selector.py b/cogs/colour_selector.py index 467fbd646..4f6c0a18e 100644 --- a/cogs/colour_selector.py +++ b/cogs/colour_selector.py @@ -12,7 +12,7 @@ from utils import CommandChecks, TeXBotBaseCog if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Collection, Sequence from collections.abc import Set as AbstractSet from logging import Logger from typing import Final @@ -26,9 +26,7 @@ logger: Final[Logger] = logging.getLogger("TeX-Bot") # TODO: Make this a config option in the future # noqa: FIX002 -COLOUR_ROLE_NAMES: Final[ - AbstractSet[str] -] = { +COLOUR_ROLE_NAMES: Final[AbstractSet[str]] = { "og-green", "pink", "orange", @@ -129,23 +127,37 @@ async def select_colour_role( ctx.interaction.user ) - roles_to_remove: list[discord.Role] = [ + roles_to_remove: Collection[discord.Role] = [ role for role in interaction_member.roles - if role.name.lower() in COLOUR_ROLE_NAMES + if role.name.lower() in COLOUR_ROLE_NAMES and role != role_to_add ] - if role_to_add in roles_to_remove: - roles_to_remove.remove(role_to_add) + member_already_has_role_to_add: bool = role_to_add in interaction_member.roles - if roles_to_remove: - await interaction_member.remove_roles( - *roles_to_remove, - reason=( - f"{interaction_member} used TeX-Bot " - f'slash-command "/select-colour-role".' - ), + 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,