Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1a4f883
Implement member colour change command
MattyTheHacker Aug 23, 2026
b27aff8
Fix errors
MattyTheHacker Aug 23, 2026
9523733
Add util function
MattyTheHacker Aug 23, 2026
8f14666
Drop in real role names
MattyTheHacker Aug 23, 2026
23ddee8
Fixes
MattyTheHacker Aug 23, 2026
fba38c6
Improvements
MattyTheHacker Aug 23, 2026
fa8f7e5
Fixes
MattyTheHacker Aug 23, 2026
38053a9
More fixes
MattyTheHacker Aug 23, 2026
5c4dd46
Merge main into colour-command
automatic-pr-updater[bot] Sep 1, 2026
439ca33
Merge main into colour-command
automatic-pr-updater[bot] Sep 15, 2026
6dc3138
Merge main into colour-command
automatic-pr-updater[bot] Sep 15, 2026
360b065
Merge main into colour-command
automatic-pr-updater[bot] Sep 15, 2026
9c4bef6
Merge main into colour-command
automatic-pr-updater[bot] Sep 15, 2026
a815f16
Merge main into colour-command
automatic-pr-updater[bot] Sep 15, 2026
0656dde
Run ruff format
MattyTheHacker Sep 15, 2026
161c037
[autofix.ci] apply automated fixes
autofix-ci[bot] Sep 15, 2026
c9104a3
Merge main into colour-command
automatic-pr-updater[bot] Sep 16, 2026
476fd0e
Merge main into colour-command
automatic-pr-updater[bot] Sep 16, 2026
1b456e4
Refactor role fetching
MattyTheHacker Sep 29, 2026
ec53b4a
Other fixes
MattyTheHacker Sep 29, 2026
abca8e5
Formatting
MattyTheHacker Sep 29, 2026
4b8c11a
Fix
MattyTheHacker Sep 29, 2026
62adb7e
More fixes
MattyTheHacker Sep 29, 2026
144b333
Reformat
MattyTheHacker Sep 29, 2026
5c41c77
Move TODO comment
MattyTheHacker Sep 29, 2026
5a6c87a
Improve removing logic
MattyTheHacker Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cogs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
CheckSUPlatformAuthorisationCommandCog,
CheckSUPlatformAuthorisationTaskCog,
)
from .colour_selector import MemberColourSelectorCommandCog
from .command_error import CommandErrorCog
from .committee_actions_tracking import (
CommitteeActionsTrackingContextCommandCog,
Expand Down Expand Up @@ -75,6 +76,7 @@
"MakeApplicantSlashCommandCog",
"MakeMemberCommandCog",
"ManualModerationCog",
"MemberColourSelectorCommandCog",
"MemberCountCommandCog",
"PingCommandCog",
"SendGetRolesRemindersTaskCog",
Expand Down Expand Up @@ -114,6 +116,7 @@ def setup(bot: TeXBot) -> None:
MakeApplicantSlashCommandCog,
MakeMemberCommandCog,
ManualModerationCog,
MemberColourSelectorCommandCog,
MemberCountCommandCog,
PingCommandCog,
SendGetRolesRemindersTaskCog,
Expand Down
30 changes: 17 additions & 13 deletions cogs/add_users_to_threads_and_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
171 changes: 171 additions & 0 deletions cogs/colour_selector.py
Original file line number Diff line number Diff line change
@@ -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():
Comment thread
MattyTheHacker marked this conversation as resolved.
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
Comment thread
MattyTheHacker marked this conversation as resolved.

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
)
8 changes: 8 additions & 0 deletions cogs/command_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion exceptions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
RolesChannelDoesNotExistError,
RulesChannelDoesNotExistError,
)
from .guild import DiscordMemberNotInMainGuildError, EveryoneRoleCouldNotBeRetrievedError
from .guild import (
DiscordMemberNotInMainGuildError,
EveryoneRoleCouldNotBeRetrievedError,
RoleNotFoundInMainGuildError,
)
from .messages import (
InvalidMessagesJSONFileError,
MessagesJSONFileMissingKeyError,
Expand Down Expand Up @@ -52,6 +56,7 @@
"NoAuditLogsStrikeTrackingError",
"RestartRequiredDueToConfigChange",
"RoleDoesNotExistError",
"RoleNotFoundInMainGuildError",
"RolesChannelDoesNotExistError",
"RulesChannelDoesNotExistError",
"StrikeTrackingError",
Expand Down
17 changes: 17 additions & 0 deletions exceptions/guild.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
__all__: Sequence[str] = (
"DiscordMemberNotInMainGuildError",
"EveryoneRoleCouldNotBeRetrievedError",
"RoleNotFoundInMainGuildError",
)


Expand All @@ -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."""

Expand Down
27 changes: 26 additions & 1 deletion utils/command_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,37 @@ 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."""
return bool(check.__name__ == cls.check_interaction_user_in_main_guild.__name__) # type: ignore[attr-defined]

@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]
Loading
Loading