Skip to content

tux.cogs.moderation.unjail

Classes:

Name Description
Unjail

Classes

Unjail(bot: Tux)

Bases: ModerationCogBase

Methods:

Name Description
get_jail_role

Get the jail role for the guild.

get_user_lock

Get or create a lock for operations on a specific user.

get_latest_jail_case

Get the latest jail case for a user.

clean_user_locks

Remove locks for users that are not currently in use.

restore_roles

Restore roles to a member with error handling.

execute_user_action_with_lock

Execute an action on a user with a lock to prevent race conditions.

execute_mod_action

Execute a moderation action with case creation, DM sending, and additional actions.

unjail

Remove a member from jail.

send_error_response

Send a standardized error response.

create_embed

Create an embed for moderation actions.

send_embed

Send an embed to the log channel.

send_dm

Send a DM to the target user.

check_conditions

Check if the conditions for the moderation action are met.

handle_case_response

Handle the response for a case.

is_pollbanned

Check if a user is poll banned.

is_snippetbanned

Check if a user is snippet banned.

is_jailed

Check if a user is jailed using the optimized latest case method.

Source code in tux/cogs/moderation/unjail.py
Python
def __init__(self, bot: Tux) -> None:
    super().__init__(bot)
    self.unjail.usage = generate_usage(self.unjail, UnjailFlags)

Functions

get_jail_role(guild: discord.Guild) -> discord.Role | None async

Get the jail role for the guild.

Parameters:

Name Type Description Default
guild Guild

The guild to get the jail role for.

required

Returns:

Type Description
Optional[Role]

The jail role, or None if not found.

Source code in tux/cogs/moderation/unjail.py
Python
async def get_jail_role(self, guild: discord.Guild) -> discord.Role | None:
    """
    Get the jail role for the guild.

    Parameters
    ----------
    guild : discord.Guild
        The guild to get the jail role for.

    Returns
    -------
    Optional[discord.Role]
        The jail role, or None if not found.
    """

    jail_role_id = await self.db.guild_config.get_jail_role_id(guild.id)
    return None if jail_role_id is None else guild.get_role(jail_role_id)
get_user_lock(user_id: int) -> Lock async

Get or create a lock for operations on a specific user. If the number of stored locks exceeds the cleanup threshold, unused locks are removed.

Parameters:

Name Type Description Default
user_id int

The ID of the user to get a lock for.

required

Returns:

Type Description
Lock

The lock for the user.

Source code in tux/cogs/moderation/unjail.py
Python
    jail_role_id = await self.db.guild_config.get_jail_role_id(guild.id)
    return None if jail_role_id is None else guild.get_role(jail_role_id)

async def get_latest_jail_case(self, guild_id: int, user_id: int) -> Case | None:
    """
    Get the latest jail case for a user.

    Parameters
    ----------
    guild_id : int
        The ID of the guild to check in.
    user_id : int
        The ID of the user to check.

    Returns
    -------
    Optional[Case]
        The latest jail case, or None if not found.
    """

    return await self.db.case.get_latest_case_by_user(
get_latest_jail_case(guild_id: int, user_id: int) -> Case | None async

Get the latest jail case for a user.

Parameters:

Name Type Description Default
guild_id int

The ID of the guild to check in.

required
user_id int

The ID of the user to check.

required

Returns:

Type Description
Optional[Case]

The latest jail case, or None if not found.

Source code in tux/cogs/moderation/unjail.py
Python
async def get_latest_jail_case(self, guild_id: int, user_id: int) -> Case | None:
    """
    Get the latest jail case for a user.

    Parameters
    ----------
    guild_id : int
        The ID of the guild to check in.
    user_id : int
        The ID of the user to check.

    Returns
    -------
    Optional[Case]
        The latest jail case, or None if not found.
    """

    return await self.db.case.get_latest_case_by_user(
        guild_id=guild_id,
        user_id=user_id,
        case_types=[CaseType.JAIL],
    )
clean_user_locks() -> None async

Remove locks for users that are not currently in use. Iterates through the locks and removes any that are not currently locked.

Source code in tux/cogs/moderation/unjail.py
Python
        case_types=[CaseType.JAIL],
    )

async def restore_roles(
    self,
    member: discord.Member,
    role_ids: list[int],
    reason: str,
) -> tuple[bool, list[discord.Role]]:
    """
    Restore roles to a member with error handling.

    Parameters
    ----------
    member : discord.Member
        The member to restore roles to.
    role_ids : List[int]
restore_roles(member: discord.Member, role_ids: list[int], reason: str) -> tuple[bool, list[discord.Role]] async

Restore roles to a member with error handling.

Parameters:

Name Type Description Default
member Member

The member to restore roles to.

required
role_ids List[int]

The IDs of the roles to restore.

required
reason str

The reason for restoring the roles.

required

Returns:

Type Description
Tuple[bool, List[Role]]

A tuple containing whether the operation was successful and which roles were restored.

Source code in tux/cogs/moderation/unjail.py
Python
async def restore_roles(
    self,
    member: discord.Member,
    role_ids: list[int],
    reason: str,
) -> tuple[bool, list[discord.Role]]:
    """
    Restore roles to a member with error handling.

    Parameters
    ----------
    member : discord.Member
        The member to restore roles to.
    role_ids : List[int]
        The IDs of the roles to restore.
    reason : str
        The reason for restoring the roles.

    Returns
    -------
    Tuple[bool, List[discord.Role]]
        A tuple containing whether the operation was successful and which roles were restored.
    """

    if not role_ids:
        return True, []

    # Filter out roles that no longer exist or can't be assigned
    guild = member.guild
    roles_to_add: list[discord.Role] = []
    skipped_roles: list[int] = []

    for role_id in role_ids:
        role = guild.get_role(role_id)
        if role and role.is_assignable():
            roles_to_add.append(role)
        else:
            skipped_roles.append(role_id)

    if skipped_roles:
        logger.warning(
            f"Skipping {len(skipped_roles)} roles that don't exist or can't be assigned: {skipped_roles}",
        )

    if not roles_to_add:
        return True, []

    # Try to add all roles at once
    try:
        await member.add_roles(*roles_to_add, reason=reason, atomic=False)

    except discord.Forbidden:
        logger.error(f"No permission to add roles to {member}")
        return False, []

    except discord.HTTPException as e:
        # If bulk add fails, try one by one
        logger.warning(f"Bulk role add failed for {member}, trying one by one: {e}")
        successful_roles: list[discord.Role] = []

        for role in roles_to_add:
            try:
                await member.add_roles(role, reason=reason)
                successful_roles.append(role)

            except Exception as role_e:
                logger.error(f"Failed to add role {role} to {member}: {role_e}")

        return bool(successful_roles), successful_roles

    else:
        return True, roles_to_add
execute_user_action_with_lock(user_id: int, action_func: Callable[..., Coroutine[Any, Any, R]], *args: Any, **kwargs: Any) -> R async

Execute an action on a user with a lock to prevent race conditions.

Parameters:

Name Type Description Default
user_id int

The ID of the user to lock.

required
action_func Callable[..., Coroutine[Any, Any, R]]

The coroutine function to execute.

required
*args Any

Arguments to pass to the function.

()
**kwargs Any

Keyword arguments to pass to the function.

{}

Returns:

Type Description
R

The result of the action function.

Source code in tux/cogs/moderation/unjail.py
Python
reason : str
    The reason for restoring the roles.

Returns
-------
Tuple[bool, List[discord.Role]]
    A tuple containing whether the operation was successful and which roles were restored.
"""

if not role_ids:
    return True, []

# Filter out roles that no longer exist or can't be assigned
guild = member.guild
roles_to_add: list[discord.Role] = []
skipped_roles: list[int] = []

for role_id in role_ids:
    role = guild.get_role(role_id)
    if role and role.is_assignable():
        roles_to_add.append(role)
    else:
        skipped_roles.append(role_id)

if skipped_roles:
    logger.warning(
        f"Skipping {len(skipped_roles)} roles that don't exist or can't be assigned: {skipped_roles}",
    )

if not roles_to_add:
_dummy_action() -> None async

Dummy coroutine for moderation actions that only create a case without performing Discord API actions. Used by commands like warn, pollban, snippetban etc. that only need case creation.

Source code in tux/cogs/moderation/unjail.py
Python
# Try to add all roles at once
try:
    await member.add_roles(*roles_to_add, reason=reason, atomic=False)

except discord.Forbidden:
execute_mod_action(ctx: commands.Context[Tux], case_type: CaseType, user: discord.Member | discord.User, reason: str, silent: bool, dm_action: str, actions: Sequence[tuple[Any, type[R]]] = (), duration: str | None = None, expires_at: datetime | None = None) -> None async

Execute a moderation action with case creation, DM sending, and additional actions.

Parameters:

Name Type Description Default
ctx Context[Tux]

The context of the command.

required
case_type CaseType

The type of case to create.

required
user Union[Member, User]

The target user of the moderation action.

required
reason str

The reason for the moderation action.

required
silent bool

Whether to send a DM to the user.

required
dm_action str

The action description for the DM.

required
actions Sequence[tuple[Any, type[R]]]

Additional actions to execute and their expected return types.

()
duration Optional[str]

The duration of the action, if applicable (for display/logging).

None
expires_at Optional[datetime]

The specific expiration time, if applicable.

None
Source code in tux/cogs/moderation/unjail.py
Python
        return False, []

    except discord.HTTPException as e:
        # If bulk add fails, try one by one
        logger.warning(f"Bulk role add failed for {member}, trying one by one: {e}")
        successful_roles: list[discord.Role] = []

        for role in roles_to_add:
            try:
                await member.add_roles(role, reason=reason)
                successful_roles.append(role)

            except Exception as role_e:
                logger.error(f"Failed to add role {role} to {member}: {role_e}")

        return bool(successful_roles), successful_roles

    else:
        return True, roles_to_add

@commands.hybrid_command(
    name="unjail",
    aliases=["uj"],
)
@commands.guild_only()
@checks.has_pl(2)
async def unjail(
    self,
    ctx: commands.Context[Tux],
    member: discord.Member,
    *,
    flags: UnjailFlags,
) -> None:
    """
    Remove a member from jail.

    Parameters
    ----------
    ctx : commands.Context[Tux]
        The context in which the command is being invoked.
    member : discord.Member
        The member to unjail.
    flags : UnjailFlags
        The flags for the command. (reason: str, silent: bool)

    Raises
    ------
    discord.Forbidden
        If the bot is unable to unjail the user.
    discord.HTTPException
        If an error occurs while unjailing the user.
    """

    assert ctx.guild

    await ctx.defer(ephemeral=True)

    # Get jail role
    jail_role = await self.get_jail_role(ctx.guild)
    if not jail_role:
        await self.send_error_response(ctx, "No jail role found.")
        return

    # Check if user is jailed
    if not await self.is_jailed(ctx.guild.id, member.id):
        await self.send_error_response(ctx, "User is not jailed.")
        return

    # Check if moderator has permission to unjail the member
    if not await self.check_conditions(ctx, member, ctx.author, "unjail"):
        return

    # Use lock to prevent race conditions
    async def perform_unjail() -> None:
        nonlocal ctx, member, jail_role, flags

        # Re-assert guild is not None inside the nested function for type safety
        assert ctx.guild is not None, "Guild context should exist here"
        guild_id = ctx.guild.id

        # Get latest jail case *before* modifying roles
        case = await self.get_latest_jail_case(guild_id, member.id)
        if not case:
            await self.send_error_response(ctx, "No jail case found.")
            return

        # Wrap core actions in try/except as suggested
        try:
            # Remove jail role from member
            assert jail_role is not None, "Jail role should not be None at this point"
            await member.remove_roles(jail_role, reason=flags.reason)
            logger.info(f"Removed jail role from {member} by {ctx.author}")

            # Insert unjail case into database
            case_result = await self.db.case.insert_case(
                case_user_id=member.id,
                case_moderator_id=ctx.author.id,
                case_type=CaseType.UNJAIL,
                case_reason=flags.reason,
                guild_id=guild_id,
            )

            # Send DM to member
            dm_sent = await self.send_dm(ctx, flags.silent, member, flags.reason, "removed from jail")

            # Handle case response - send embed immediately
unjail(ctx: commands.Context[Tux], member: discord.Member, *, flags: UnjailFlags) -> None async

Remove a member from jail.

Parameters:

Name Type Description Default
ctx Context[Tux]

The context in which the command is being invoked.

required
member Member

The member to unjail.

required
flags UnjailFlags

The flags for the command. (reason: str, silent: bool)

required

Raises:

Type Description
Forbidden

If the bot is unable to unjail the user.

HTTPException

If an error occurs while unjailing the user.

Source code in tux/cogs/moderation/unjail.py
Python
@commands.hybrid_command(
    name="unjail",
    aliases=["uj"],
)
@commands.guild_only()
@checks.has_pl(2)
async def unjail(
    self,
    ctx: commands.Context[Tux],
    member: discord.Member,
    *,
    flags: UnjailFlags,
) -> None:
    """
    Remove a member from jail.

    Parameters
    ----------
    ctx : commands.Context[Tux]
        The context in which the command is being invoked.
    member : discord.Member
        The member to unjail.
    flags : UnjailFlags
        The flags for the command. (reason: str, silent: bool)

    Raises
    ------
    discord.Forbidden
        If the bot is unable to unjail the user.
    discord.HTTPException
        If an error occurs while unjailing the user.
    """

    assert ctx.guild

    await ctx.defer(ephemeral=True)

    # Get jail role
    jail_role = await self.get_jail_role(ctx.guild)
    if not jail_role:
        await self.send_error_response(ctx, "No jail role found.")
        return

    # Check if user is jailed
    if not await self.is_jailed(ctx.guild.id, member.id):
        await self.send_error_response(ctx, "User is not jailed.")
        return

    # Check if moderator has permission to unjail the member
    if not await self.check_conditions(ctx, member, ctx.author, "unjail"):
        return

    # Use lock to prevent race conditions
    async def perform_unjail() -> None:
        nonlocal ctx, member, jail_role, flags

        # Re-assert guild is not None inside the nested function for type safety
        assert ctx.guild is not None, "Guild context should exist here"
        guild_id = ctx.guild.id

        # Get latest jail case *before* modifying roles
        case = await self.get_latest_jail_case(guild_id, member.id)
        if not case:
            await self.send_error_response(ctx, "No jail case found.")
            return

        # Wrap core actions in try/except as suggested
        try:
            # Remove jail role from member
            assert jail_role is not None, "Jail role should not be None at this point"
            await member.remove_roles(jail_role, reason=flags.reason)
            logger.info(f"Removed jail role from {member} by {ctx.author}")

            # Insert unjail case into database
            case_result = await self.db.case.insert_case(
                case_user_id=member.id,
                case_moderator_id=ctx.author.id,
                case_type=CaseType.UNJAIL,
                case_reason=flags.reason,
                guild_id=guild_id,
            )

            # Send DM to member
            dm_sent = await self.send_dm(ctx, flags.silent, member, flags.reason, "removed from jail")

            # Handle case response - send embed immediately
            await self.handle_case_response(
                ctx,
                CaseType.UNJAIL,
                case_result.case_number,
                flags.reason,
                member,
                dm_sent,
            )

            # Add roles back to member after sending the response
            if case.case_user_roles:
                success, restored_roles = await self.restore_roles(member, case.case_user_roles, flags.reason)
                if success and restored_roles:
                    logger.info(f"Restored {len(restored_roles)} roles to {member}")

                    # Restore the role verification logic here
                    # Shorter wait time for roles to be applied by Discord
                    await asyncio.sleep(0.5)

                    # Verify if all roles were successfully added back
                    # Check ctx.guild again for safety within this block
                    if ctx.guild and case.case_user_roles:
                        # Check for missing roles in a simpler way
                        member_role_ids = {role.id for role in member.roles}
                        missing_roles: list[str] = []

                        for role_id in case.case_user_roles:
                            if role_id not in member_role_ids:
                                role = ctx.guild.get_role(role_id)
                                role_name = role.name if role else str(role_id)
                                missing_roles.append(role_name)

                        if missing_roles:
                            missing_str = ", ".join(missing_roles)
                            logger.warning(f"Failed to restore roles for {member}: {missing_str}")
                            # Optionally notify moderator/user if roles failed to restore
                            # Example: await ctx.send(f"Note: Some roles couldn't be restored: {missing_str}", ephemeral=True)

                elif not restored_roles:
                    logger.warning(
                        f"No roles to restore for {member} or restore action failed partially/completely.",
                    )

        except (discord.Forbidden, discord.HTTPException) as e:
            # Specific Discord API errors during role removal or subsequent actions
            error_message = f"Failed to unjail {member}: Discord API error."
            logger.error(f"{error_message} Details: {e}")
            await self.send_error_response(ctx, error_message, e)
            # No specific rollback needed, but ensure case is not created/logged incorrectly if needed

        except Exception as e:
            # Catch any other unexpected error
            error_message = f"An unexpected error occurred while unjailing {member}."
            logger.exception(f"{error_message}", exc_info=e)  # Use logger.exception for traceback
            await self.send_error_response(ctx, error_message)
            # No specific rollback needed

    # Execute the locked action
    await self.execute_user_action_with_lock(member.id, perform_unjail)
_handle_dm_result(user: discord.Member | discord.User, dm_result: Any) -> bool

Handle the result of sending a DM.

Parameters:

Name Type Description Default
user Union[Member, User]

The user the DM was sent to.

required
dm_result Any

The result of the DM sending operation.

required

Returns:

Type Description
bool

Whether the DM was successfully sent.

Source code in tux/cogs/moderation/unjail.py
Python
    ctx,
    CaseType.UNJAIL,
    case_result.case_number,
    flags.reason,
    member,
    dm_sent,
)

# Add roles back to member after sending the response
if case.case_user_roles:
    success, restored_roles = await self.restore_roles(member, case.case_user_roles, flags.reason)
    if success and restored_roles:
        logger.info(f"Restored {len(restored_roles)} roles to {member}")

        # Restore the role verification logic here
        # Shorter wait time for roles to be applied by Discord
        await asyncio.sleep(0.5)

        # Verify if all roles were successfully added back
        # Check ctx.guild again for safety within this block
        if ctx.guild and case.case_user_roles:
            # Check for missing roles in a simpler way
send_error_response(ctx: commands.Context[Tux], error_message: str, error_detail: Exception | None = None, ephemeral: bool = True) -> None async

Send a standardized error response.

Parameters:

Name Type Description Default
ctx Context[Tux]

The context of the command.

required
error_message str

The error message to display.

required
error_detail Optional[Exception]

The exception details, if available.

None
ephemeral bool

Whether the message should be ephemeral.

True
Source code in tux/cogs/moderation/unjail.py
Python
                missing_roles: list[str] = []

                for role_id in case.case_user_roles:
                    if role_id not in member_role_ids:
                        role = ctx.guild.get_role(role_id)
                        role_name = role.name if role else str(role_id)
                        missing_roles.append(role_name)

                if missing_roles:
                    missing_str = ", ".join(missing_roles)
                    logger.warning(f"Failed to restore roles for {member}: {missing_str}")
                    # Optionally notify moderator/user if roles failed to restore
                    # Example: await ctx.send(f"Note: Some roles couldn't be restored: {missing_str}", ephemeral=True)

        elif not restored_roles:
            logger.warning(
                f"No roles to restore for {member} or restore action failed partially/completely.",
            )

except (discord.Forbidden, discord.HTTPException) as e:
    # Specific Discord API errors during role removal or subsequent actions
    error_message = f"Failed to unjail {member}: Discord API error."
    logger.error(f"{error_message} Details: {e}")
    await self.send_error_response(ctx, error_message, e)
    # No specific rollback needed, but ensure case is not created/logged incorrectly if needed

except Exception as e:
    # Catch any other unexpected error
    error_message = f"An unexpected error occurred while unjailing {member}."
    logger.exception(f"{error_message}", exc_info=e)  # Use logger.exception for traceback
    await self.send_error_response(ctx, error_message)
    # No specific rollback needed
create_embed(ctx: commands.Context[Tux], title: str, fields: list[tuple[str, str, bool]], color: int, icon_url: str, timestamp: datetime | None = None, thumbnail_url: str | None = None) -> discord.Embed

Create an embed for moderation actions.

Parameters:

Name Type Description Default
ctx Context[Tux]

The context of the command.

required
title str

The title of the embed.

required
fields list[tuple[str, str, bool]]

The fields to add to the embed.

required
color int

The color of the embed.

required
icon_url str

The icon URL for the embed.

required
timestamp Optional[datetime]

The timestamp for the embed.

None
thumbnail_url Optional[str]

The thumbnail URL for the embed.

None

Returns:

Type Description
Embed

The embed for the moderation action.

Source code in tux/cogs/moderation/unjail.py
Python
        # Execute the locked action
        await self.execute_user_action_with_lock(member.id, perform_unjail)


async def setup(bot: Tux) -> None:
    await bot.add_cog(Unjail(bot))
send_embed(ctx: commands.Context[Tux], embed: discord.Embed, log_type: str) -> None async

Send an embed to the log channel.

Parameters:

Name Type Description Default
ctx Context[Tux]

The context of the command.

required
embed Embed

The embed to send.

required
log_type str

The type of log to send the embed to.

required
send_dm(ctx: commands.Context[Tux], silent: bool, user: discord.Member | discord.User, reason: str, action: str) -> bool async

Send a DM to the target user.

Parameters:

Name Type Description Default
ctx Context[Tux]

The context of the command.

required
silent bool

Whether the command is silent.

required
user Union[Member, User]

The target of the moderation action.

required
reason str

The reason for the moderation action.

required
action str

The action being performed.

required

Returns:

Type Description
bool

Whether the DM was successfully sent.

check_conditions(ctx: commands.Context[Tux], user: discord.Member | discord.User, moderator: discord.Member | discord.User, action: str) -> bool async

Check if the conditions for the moderation action are met.

Parameters:

Name Type Description Default
ctx Context[Tux]

The context of the command.

required
user Union[Member, User]

The target of the moderation action.

required
moderator Union[Member, User]

The moderator of the moderation action.

required
action str

The action being performed.

required

Returns:

Type Description
bool

Whether the conditions are met.

handle_case_response(ctx: commands.Context[Tux], case_type: CaseType, case_number: int | None, reason: str, user: discord.Member | discord.User, dm_sent: bool, duration: str | None = None) -> None async

Handle the response for a case.

Parameters:

Name Type Description Default
ctx Context[Tux]

The context of the command.

required
case_type CaseType

The type of case.

required
case_number Optional[int]

The case number.

required
reason str

The reason for the case.

required
user Union[Member, User]

The target of the case.

required
dm_sent bool

Whether the DM was sent.

required
duration Optional[str]

The duration of the case.

None
_format_case_title(case_type: CaseType, case_number: int | None, duration: str | None) -> str

Format a case title.

Parameters:

Name Type Description Default
case_type CaseType

The type of case.

required
case_number Optional[int]

The case number.

required
duration Optional[str]

The duration of the case.

required

Returns:

Type Description
str

The formatted case title.

is_pollbanned(guild_id: int, user_id: int) -> bool async

Check if a user is poll banned.

Parameters:

Name Type Description Default
guild_id int

The ID of the guild to check in.

required
user_id int

The ID of the user to check.

required

Returns:

Type Description
bool

True if the user is poll banned, False otherwise.

is_snippetbanned(guild_id: int, user_id: int) -> bool async

Check if a user is snippet banned.

Parameters:

Name Type Description Default
guild_id int

The ID of the guild to check in.

required
user_id int

The ID of the user to check.

required

Returns:

Type Description
bool

True if the user is snippet banned, False otherwise.

is_jailed(guild_id: int, user_id: int) -> bool async

Check if a user is jailed using the optimized latest case method.

Parameters:

Name Type Description Default
guild_id int

The ID of the guild to check in.

required
user_id int

The ID of the user to check.

required

Returns:

Type Description
bool

True if the user is jailed, False otherwise.

Functions