Skip to content

Commit

Permalink
Create command to give applicant role (#232)
Browse files Browse the repository at this point in the history
  • Loading branch information
MattyTheHacker authored Jun 14, 2024
1 parent 41dc9e7 commit 7c3d7ee
Show file tree
Hide file tree
Showing 8 changed files with 267 additions and 10 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ The meaning of each error code is given here:
* `E1024` - Your [Discord guild](https://discord.com/developers/docs/resources/guild) does not contain a [role](https://discord.com/developers/docs/topics/permissions#role-object) with the name "**@Archivist**".
(This [role](https://discord.com/developers/docs/topics/permissions#role-object) is required for the `/archive` [command](https://discord.com/developers/docs/interactions/application-commands))

* `E1025` - Your [Discord guild](https://discord.com/developers/docs/resources/guild) does not contain a [role](https://discord.com/developers/docs/topics/permissions#role-object) with the name "**@Applicant**". (This [role](https://discord.com/developers/docs/topics/permissions#role-object) is required for the `/make-applicant` [command](https://discord.com/developers/docs/interactions/application-commands) and respective user and message commands)

* `E1031` - Your [Discord guild](https://discord.com/developers/docs/resources/guild) does not contain a [text channel](https://docs.pycord.dev/en/stable/api/models.html#discord.TextChannel) with the name "#**roles**".
(This [text channel](https://docs.pycord.dev/en/stable/api/models.html#discord.TextChannel) is required for the `/writeroles` [command](https://discord.com/developers/docs/interactions/application-commands))

Expand Down
5 changes: 5 additions & 0 deletions cogs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"DeleteAllCommandsCog",
"EditMessageCommandCog",
"EnsureMembersInductedCommandCog",
"MakeApplicantSlashCommandCog",
"MakeApplicantContextCommandsCog",
"InductSlashCommandCog",
"InductSendMessageCog",
"InductContextCommandsCog",
Expand Down Expand Up @@ -47,6 +49,7 @@
InductSlashCommandCog,
)
from cogs.kill import KillCommandCog
from cogs.make_applicant import MakeApplicantContextCommandsCog, MakeApplicantSlashCommandCog
from cogs.make_member import MakeMemberCommandCog
from cogs.ping import PingCommandCog
from cogs.remind_me import ClearRemindersBacklogTaskCog, RemindMeCommandCog
Expand Down Expand Up @@ -77,6 +80,8 @@ def setup(bot: TeXBot) -> None:
InductSendMessageCog,
InductContextCommandsCog,
KillCommandCog,
MakeApplicantSlashCommandCog,
MakeApplicantContextCommandsCog,
MakeMemberCommandCog,
PingCommandCog,
ClearRemindersBacklogTaskCog,
Expand Down
8 changes: 4 additions & 4 deletions cogs/induct.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from config import settings
from db.core.models import IntroductionReminderOptOutMember
from exceptions import (
ApplicantRoleDoesNotExistError,
CommitteeRoleDoesNotExistError,
GuestRoleDoesNotExistError,
GuildDoesNotExistError,
Expand Down Expand Up @@ -237,10 +238,9 @@ async def _perform_induction(self, ctx: TeXBotApplicationContext, induction_memb
reason=f"{ctx.user} used TeX Bot slash-command: \"/induct\"",
)

applicant_role: discord.Role | None = discord.utils.get(
main_guild.roles,
name="Applicant",
)
applicant_role: discord.Role | None = None
with contextlib.suppress(ApplicantRoleDoesNotExistError):
applicant_role = await ctx.bot.applicant_role

if applicant_role and applicant_role in induction_member.roles:
await induction_member.remove_roles(
Expand Down
202 changes: 202 additions & 0 deletions cogs/make_applicant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""Contains cog classes for making a user into an applicant."""

from collections.abc import Sequence

__all__: Sequence[str] = (
"BaseMakeApplicantCog",
"MakeApplicantSlashCommandCog",
"MakeApplicantContextCommandsCog",
)


import logging
from logging import Logger
from typing import Final

import discord

from exceptions.does_not_exist import ApplicantRoleDoesNotExistError, GuildDoesNotExistError
from utils import CommandChecks, TeXBotApplicationContext, TeXBotBaseCog

logger: Logger = logging.getLogger("TeX-Bot")


class BaseMakeApplicantCog(TeXBotBaseCog):
"""
Base making-applicant cog container class.
Defines the methods for making users into group-applicants, that are called by
child cog container classes.
"""

async def _perform_make_applicant(self, ctx: TeXBotApplicationContext, applicant_member: discord.Member) -> None: # noqa: E501
"""Perform the actual process of making the user into a group-applicant."""
main_guild: discord.Guild = ctx.bot.main_guild
applicant_role: discord.Role = await ctx.bot.applicant_role
guest_role: discord.Role = await ctx.bot.guest_role

intro_channel: discord.TextChannel | None = discord.utils.get(
main_guild.text_channels,
name="introductions",
)

if applicant_member.bot:
await self.command_send_error(ctx, message="Cannot make a bot user an applicant!")
return

initial_response: discord.Interaction | discord.WebhookMessage = await ctx.respond(
":hourglass: Attempting to make user an applicant... :hourglass:",
ephemeral=True,
)

AUDIT_MESSAGE: Final[str] = f"{ctx.user} used TeX Bot Command \"Make User Applicant\""

await applicant_member.add_roles(applicant_role, reason=AUDIT_MESSAGE)

logger.debug("Applicant role given to user %s", applicant_member)

if guest_role in applicant_member.roles:
await applicant_member.remove_roles(guest_role, reason=AUDIT_MESSAGE)
logger.debug("Removed Guest role from user %s", applicant_member)


tex_emoji: discord.Emoji | None = self.bot.get_emoji(743218410409820213)
if not tex_emoji:
tex_emoji = discord.utils.get(main_guild.emojis, name="TeX")

if intro_channel:
recent_message: discord.Message
for recent_message in await intro_channel.history(limit=30).flatten():
if recent_message.author.id == applicant_member.id:
forbidden_error: discord.Forbidden
try:
if tex_emoji:
await recent_message.add_reaction(tex_emoji)
await recent_message.add_reaction("👋")
except discord.Forbidden as forbidden_error:
if "90001" not in str(forbidden_error):
raise forbidden_error from forbidden_error

logger.info(
"Failed to add reactions because the user, %s, "
"has blocked the bot.",
recent_message.author,
)
break

await initial_response.edit(content=":white_check_mark: User is now an applicant.")


class MakeApplicantSlashCommandCog(BaseMakeApplicantCog):
"""Cog class that defines the "/make_applicant" slash-command."""

@staticmethod
async def autocomplete_get_members(ctx: TeXBotApplicationContext) -> set[discord.OptionChoice]: # noqa: E501
"""
Autocomplete callable that generates the set of available selectable members.
This list of selectable members is used in any of the "make_applicant" slash-command
options that have a member input-type.
"""
try:
guild: discord.Guild = ctx.bot.main_guild
applicant_role: discord.Role = await ctx.bot.applicant_role
except (GuildDoesNotExistError, ApplicantRoleDoesNotExistError):
return set()

members: set[discord.Member] = {
member
for member
in guild.members
if not member.bot and applicant_role not in member.roles
}

if not ctx.value or ctx.value.startswith("@"):
return {
discord.OptionChoice(name=f"@{member.name}", value=str(member.id))
for member
in members
}

return {
discord.OptionChoice(name=member.name, value=str(member.id))
for member
in members
}


@discord.slash_command( # type: ignore[no-untyped-call, misc]
name="make-applicant",
description=(
"Gives the user @Applicant role and removes the @Guest role if present."
),
)
@discord.option( # type: ignore[no-untyped-call, misc]
name="user",
description="The user to make an Applicant",
input_type=str,
autocomplete=discord.utils.basic_autocomplete(autocomplete_get_members), # type: ignore[arg-type]
required=True,
parameter_name="str_applicant_member_id",
)
@CommandChecks.check_interaction_user_has_committee_role
@CommandChecks.check_interaction_user_in_main_guild
async def make_applicant(self, ctx: TeXBotApplicationContext, str_applicant_member_id: str) -> None: # noqa: E501
"""
Definition & callback response of the "make_applicant" command.
The "make_applicant" command gives the specified user the "Applicant" role and
removes the "Guest" role if they have it.
"""
member_id_not_integer_error: ValueError
try:
applicant_member: discord.Member = await self.bot.get_member_from_str_id(
str_applicant_member_id,
)
except ValueError as member_id_not_integer_error:
await self.command_send_error(ctx, message=member_id_not_integer_error.args[0])
return

await self._perform_make_applicant(ctx, applicant_member)


class MakeApplicantContextCommandsCog(BaseMakeApplicantCog):
"""Cog class that defines the "/make_applicant" context commands."""

@discord.user_command(name="Make Applicant") #type: ignore[no-untyped-call, misc]
@CommandChecks.check_interaction_user_has_committee_role
@CommandChecks.check_interaction_user_in_main_guild
async def user_make_applicant(self, ctx: TeXBotApplicationContext, member: discord.Member) -> None: # noqa: E501
"""
Definition and callback response of the "make_applicant" user-context-command.
The "make_applicant" user-context-command executes the same process as
the "make_applicant" slash-command, and thus gives the specified user the
"Applicant" role and removes the "Guest" role if they have it.
"""
await self._perform_make_applicant(ctx, member)

@discord.MessageCommand(name="Make Message Author Applicant") # type: ignore[misc]
@CommandChecks.check_interaction_user_has_committee_role
@CommandChecks.check_interaction_user_in_main_guild
async def message_make_applicant(self, ctx: TeXBotApplicationContext, message: discord.Message) -> None: # noqa: E501
"""
Definition of the "message_make_applicant" message-context-command.
The "make_applicant" message-context-command executes the same process as
the "make_applicant" slash-command, and thus gives the specified user the
"Applicant" role and removes the "Guest" role if they have it.
"""
try:
member: discord.Member = await self.bot.get_member_from_str_id(
str(message.author.id),
)
except ValueError:
await ctx.respond((
":information_source: No changes made. User cannot be made into an applicant "
"because they have left the server :information_source:"
),
ephemeral=True,
)

await self._perform_make_applicant(ctx, member)
16 changes: 10 additions & 6 deletions cogs/make_member.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@

from config import settings
from db.core.models import GroupMadeMember
from exceptions import CommitteeRoleDoesNotExistError, GuestRoleDoesNotExistError
from exceptions import (
ApplicantRoleDoesNotExistError,
CommitteeRoleDoesNotExistError,
GuestRoleDoesNotExistError,
)
from utils import CommandChecks, TeXBotApplicationContext, TeXBotBaseCog

logger: Logger = logging.getLogger("TeX-Bot")
Expand Down Expand Up @@ -94,7 +98,7 @@ class MakeMemberCommandCog(TeXBotBaseCog):
parameter_name="group_member_id",
)
@CommandChecks.check_interaction_user_in_main_guild
async def make_member(self, ctx: TeXBotApplicationContext, group_member_id: str) -> None:
async def make_member(self, ctx: TeXBotApplicationContext, group_member_id: str) -> None: # noqa: PLR0915
"""
Definition & callback response of the "make_member" command.
Expand Down Expand Up @@ -255,10 +259,10 @@ async def make_member(self, ctx: TeXBotApplicationContext, group_member_id: str)
reason="TeX Bot slash-command: \"/makemember\"",
)

applicant_role: discord.Role | None = discord.utils.get(
self.bot.main_guild.roles,
name="Applicant",
)
applicant_role: discord.Role | None = None
with contextlib.suppress(ApplicantRoleDoesNotExistError):
applicant_role = await ctx.bot.applicant_role

if applicant_role and applicant_role in interaction_member.roles:
await interaction_member.remove_roles(
applicant_role,
Expand Down
2 changes: 2 additions & 0 deletions exceptions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from collections.abc import Sequence

__all__: Sequence[str] = (
"ApplicantRoleDoesNotExistError",
"ArchivistRoleDoesNotExistError",
"ChannelDoesNotExistError",
"CommitteeRoleDoesNotExistError",
Expand All @@ -29,6 +30,7 @@
ImproperlyConfiguredError,
)
from .does_not_exist import (
ApplicantRoleDoesNotExistError,
ArchivistRoleDoesNotExistError,
ChannelDoesNotExistError,
CommitteeRoleDoesNotExistError,
Expand Down
23 changes: 23 additions & 0 deletions exceptions/does_not_exist.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,29 @@ def ROLE_NAME(cls) -> str: # noqa: N802,N805
"""The name of the Discord role that does not exist.""" # noqa: D401
return "Archivist"

class ApplicantRoleDoesNotExistError(RoleDoesNotExistError):
"""Exception class to raise when the "Applicant" Discord role is missing."""

@classproperty
def ERROR_CODE(cls) -> str: # noqa: N802, N805
"""The unique error code for users to tell admins about an error that occured.""" # noqa: D401
return "E1025"

@classproperty
def DEPENDENT_COMMANDS(cls) -> frozenset[str]: # noqa: N802, N805
"""
The set of names of bot commands that require this Discord entity.
This set being empty could mean thta all bot commands require this entity,
or that none of them do.
""" # noqa: D401
return frozenset({"make_applicant"})

@classproperty
def ROLE_NAME(cls) -> str: # noqa: N802, N805
"""The name of the Discord role that does not exist.""" # noqa: D401
return "Applicant"


class ChannelDoesNotExistError(BaseDoesNotExistError):
"""Exception class to raise when a required Discord channel is missing."""
Expand Down
19 changes: 19 additions & 0 deletions utils/tex_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from config import settings
from exceptions import (
ApplicantRoleDoesNotExistError,
ArchivistRoleDoesNotExistError,
CommitteeRoleDoesNotExistError,
DiscordMemberNotInMainGuildError,
Expand Down Expand Up @@ -176,6 +177,24 @@ async def archivist_role(self) -> discord.Role:

return self._archivist_role

@property
async def applicant_role(self) -> discord.Role:
"""
Shortcut accessor to the applicant role.
The applicant role allows users to see the specific applicant channels.
"""
if not self._applicant_role or not self._guild_has_role(self._applicant_role):
self._applicant_role = discord.utils.get(
await self.main_guild.fetch_roles(),
name="Applicant",
)

if not self._applicant_role:
raise ApplicantRoleDoesNotExistError

return self._applicant_role

@property
async def roles_channel(self) -> discord.TextChannel:
"""
Expand Down

0 comments on commit 7c3d7ee

Please sign in to comment.