Skip to content

timeout updates #287

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,13 +363,20 @@ This cog exposes a HTTP endpoint for exporting guild metrics in Prometheus forma
Manage the timeout status of users.

Run the command to add a user to timeout, run it again to remove them. Append a reason if you wish: `[p]timeout @someUser said a bad thing`

If the user is not in timeout, they are added. If they are in timeout, they are removed.

All of the member's roles will be stripped when they are added to timeout, and re-added when they are removed.

This cog is designed for guilds with a private channel used to discuss infractions or otherwise with a given member 1-1.
This private channel should be readable only by mods, admins, and the timeout role.

**Note:** This cog does not manage Discord's builtin "time out" functionality. It is unrelated.

- `[p]timeout <user> [reason]` - Add/remove a user from timeout, optionally specifying a reason.
- `[p]timeoutset list` - Print the current configuration.
- `[p]timeoutset role <role name>` - Set the timeout role.
- `[p]timeoutset report <bool>` - Set whether timeout reports should be logged or not.
- `[p]timeoutset report <enable|disable>` - Set whether timeout reports should be logged or not.
- `[p]timeoutset logchannel <channel>` - Set the log channel.

### Topic
Expand Down
212 changes: 176 additions & 36 deletions timeout/timeout.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import datetime
import logging
from datetime import datetime as dt
from typing import Literal

import discord
from redbot.core import Config, checks, commands
from redbot.core.utils.menus import start_adding_reactions
from redbot.core.utils.mod import is_mod_or_superior as is_mod
from redbot.core.utils.predicates import ReactionPredicate

log = logging.getLogger("red.rhomelab.timeout")

Expand All @@ -13,32 +17,58 @@ class Timeout(commands.Cog):
def __init__(self):
self.config = Config.get_conf(self, identifier=539343858187161140)
default_guild = {
"logchannel": "",
"report": "",
"timeoutrole": ""
"logchannel": None,
"report": False,
"timeoutrole": None,
"timeout_channel": None
}
self.config.register_guild(**default_guild)
self.config.register_member(
roles=[]
)

self.actor: str = None
self.target: str = None
self.actor: str | None = None
self.target: str | None = None

# Helper functions

async def member_data_cleanup(self, ctx: commands.Context):
"""Remove data stored for members who are no longer in the guild
This helps avoid permanently storing role lists for members who left whilst in timeout.
"""
# This should never happen due to checks in other functions, but it doesn't hurt to have it and it satisfies type checkers.
if ctx.guild is None:
raise TypeError("ctx.guild is None")

member_data = await self.config.all_members(ctx.guild)

for member in member_data:
# If member not found in guild...
if ctx.guild.get_member(member) is None:
# Clear member data
await self.config.member_from_ids(ctx.guild.id, member).clear()

async def report_handler(self, ctx: commands.Context, user: discord.Member, action_info: dict):
"""Build and send embed reports"""

# This should never happen due to checks in other functions, but it doesn't hurt to have it and it satisfies type checkers.
if ctx.guild is None:
raise TypeError("ctx.guild is None")

# Retrieve log channel
log_channel_config = await self.config.guild(ctx.guild).logchannel()
log_channel = ctx.guild.get_channel(log_channel_config)

# Again, this shouldn't happen due to checks in `timeoutset_logchannel`, but it doesn't hurt to have it and it satisfies type checkers.
if not isinstance(log_channel, discord.TextChannel):
await ctx.send(f"The configured log channel ({log_channel_config}) was not found or is not a text channel.")
return

# Build embed
embed = discord.Embed(
description=f"{user.mention} ({user.id})",
color=(await ctx.embed_colour()),
timestamp=datetime.datetime.utcnow()
timestamp=dt.utcnow()
)
embed.add_field(
name="Moderator",
Expand Down Expand Up @@ -70,6 +100,11 @@ async def timeout_add(
timeout_role: discord.Role,
timeout_roleset: list[discord.Role]):
"""Retrieve and save user's roles, then add user to timeout"""

# This should never happen due to checks in other functions, but it doesn't hurt to have it and it satisfies type checkers.
if ctx.guild is None:
raise TypeError("ctx.guild is None")

# Catch users already holding timeout role.
# This could be caused by an error in this cog's logic or,
# more likely, someone manually adding the user to the role.
Expand All @@ -94,7 +129,7 @@ async def timeout_add(
await user.edit(roles=timeout_roleset)
log.info("User %s added to timeout by %s.", self.target, self.actor)
except AttributeError:
await ctx.send("Please set the timeout role using `[p]timeoutset role`.")
await ctx.send(f"Please set the timeout role with `{ctx.clean_prefix}timeoutset role`.")
return
except discord.Forbidden as error:
await ctx.send("Whoops, looks like I don't have permission to do that.")
Expand All @@ -111,7 +146,7 @@ async def timeout_add(
f"Attempted new roles: {timeout_roleset}", exc_info=error
)
else:
await ctx.message.add_reaction("✅")
await ctx.tick()

# Send report to channel
if await self.config.guild(ctx.guild).report():
Expand All @@ -123,6 +158,20 @@ async def timeout_add(

async def timeout_remove(self, ctx: commands.Context, user: discord.Member, reason: str):
"""Remove user from timeout"""

# This should never happen due to checks in other functions, but it doesn't hurt to have it and it satisfies type checkers.
if ctx.guild is None:
raise TypeError("ctx.guild is None")

# Retrieve timeout channel
timeout_channel_config = await self.config.guild(ctx.guild).timeout_channel()
timeout_channel = ctx.guild.get_channel(timeout_channel_config)

# Again, this shouldn't happen due to checks in `timeoutset_timeout_channel`, but it doesn't hurt to have it and it satisfies type checkers.
if not isinstance(timeout_channel, discord.TextChannel):
await ctx.send(f"The configured log channel ({timeout_channel_config}) was not found or is not a text channel.")
return

# Fetch and define user's previous roles.
user_roles = []
for role in await self.config.member(user).roles():
Expand All @@ -147,7 +196,7 @@ async def timeout_remove(self, ctx: commands.Context, user: discord.Member, reas
f"Attempted new roles: {user_roles}", exc_info=error
)
else:
await ctx.message.add_reaction("✅")
await ctx.tick()

# Clear user's roles from config
await self.config.member(user).clear()
Expand All @@ -160,6 +209,17 @@ async def timeout_remove(self, ctx: commands.Context, user: discord.Member, reas
}
await self.report_handler(ctx, user, action_info)

# Ask if user wishes to clear the timeout channel if they've defined one
if timeout_channel:
archive_query = await ctx.send(f"Do you wish to clear the contents of {timeout_channel.mention}?")
start_adding_reactions(archive_query, ReactionPredicate.YES_OR_NO_EMOJIS)

pred = ReactionPredicate.yes_or_no(archive_query, ctx.author)
await ctx.bot.wait_for("reaction_add", check=pred)
if pred.result is True:
purge = await timeout_channel.purge(bulk=True)
await ctx.send(f"Cleared {len(purge)} messages from {timeout_channel.mention}.")

# Commands

@commands.guild_only()
Expand All @@ -177,33 +237,51 @@ async def timeoutset_logchannel(self, ctx: commands.Context, channel: discord.Te
Example:
- `[p]timeoutset logchannel #mod-log`
"""
# This should never happen due to the guild_only decorator, but it doesn't hurt to have it and it satisfies type checkers.
if ctx.guild is None:
raise TypeError("ctx.guild is None")

await self.config.guild(ctx.guild).logchannel.set(channel.id)
await ctx.message.add_reaction("✅")
await ctx.tick()

@timeoutset.command(name="report")
@timeoutset.command(name="report", usage="<enable|disable>")
@checks.mod()
async def timeoutset_report(self, ctx: commands.Context, choice: str):
"""Whether to send a report when a user is added or removed from timeout.
async def timeoutset_report(self, ctx: commands.Context, choice: Literal['enable', 'disable']):
"""Whether to send a report when a user's timeout status is updated.

These reports will be sent in the form of an embed with timeout reason to the configured log channel.
Set log channel with `[p]timeoutset logchannel`.
These reports will be sent to the configured log channel as an embed.
The embed will specify the user's details and the moderator who executed the command.

Example:
- `[p]timeoutset report [choice]`
Set log channel with `[p]timeoutset logchannel` before enabling reporting.

Possible choices are:
- `true` or `yes`: Reports will be sent.
- `false` or `no`: Reports will not be sent.
Example:
- `[p]timeoutset report enable`
- `[p]timeoutset report disable`
"""

if str.lower(choice) in ["true", "yes"]:
await self.config.guild(ctx.guild).report.set(True)
await ctx.message.add_reaction("✅")
elif str.lower(choice) in ["false", "no"]:
# This should never happen due to the guild_only decorator, but it doesn't hurt to have it and it satisfies type checkers.
if ctx.guild is None:
raise TypeError("ctx.guild is None")

# Ensure log channel has been defined
log_channel = await self.config.guild(ctx.guild).logchannel()

if choice.lower() == "enable":
if log_channel:
await self.config.guild(ctx.guild).report.set(True)
await ctx.tick()
else:
await ctx.send(
"You must set the log channel before enabling reports.\n" +
f"Set the log channel with `{ctx.clean_prefix}timeoutset logchannel`."
)

elif choice.lower() == "disable":
await self.config.guild(ctx.guild).report.set(False)
await ctx.message.add_reaction("✅")
await ctx.tick()

else:
await ctx.send("Choices: true/yes or false/no")
await ctx.send("Setting must be `enable` or `disable`.")

@timeoutset.command(name="role")
@checks.mod()
Expand All @@ -213,30 +291,62 @@ async def timeoutset_role(self, ctx: commands.Context, role: discord.Role):
Example:
- `[p]timeoutset role MyRole`
"""
# This should never happen due to the guild_only decorator, but it doesn't hurt to have it and it satisfies type checkers.
if ctx.guild is None:
raise TypeError("ctx.guild is None")

await self.config.guild(ctx.guild).timeoutrole.set(role.id)
await ctx.message.add_reaction("✅")
await ctx.tick()

@timeoutset.command(name="timeoutchannel")
@checks.mod()
async def timeoutset_timeout_channel(self, ctx: commands.Context, channel: discord.TextChannel):
"""Set the timeout channel.

This is required if you wish to optionaly purge the channel upon removing a user from timeout.

Example:
- `[p]timeoutset timeoutchannel #timeout`
"""
# This should never happen due to the guild_only decorator, but it doesn't hurt to have it and it satisfies type checkers.
if ctx.guild is None:
raise TypeError("ctx.guild is None")

await self.config.guild(ctx.guild).timeout_channel.set(channel.id)
await ctx.tick()

@timeoutset.command(name="list", aliases=["show", "view", "settings"])
@checks.mod()
async def timeoutset_list(self, ctx: commands.Context):
"""Show current settings."""
# This should never happen due to the guild_only decorator, but it doesn't hurt to have it and it satisfies type checkers.
if ctx.guild is None:
raise TypeError("ctx.guild is None")

log_channel = await self.config.guild(ctx.guild).logchannel()
report = await self.config.guild(ctx.guild).report()
timeout_role = ctx.guild.get_role(await self.config.guild(ctx.guild).timeoutrole())
timeout_channel = await self.config.guild(ctx.guild).timeout_channel()

if log_channel:
log_channel = f"<#{log_channel}>"
else:
log_channel = "Unconfigured"

if timeout_role is not None:
if timeout_role:
timeout_role = timeout_role.name
else:
timeout_role = "Unconfigured"

if report == "":
report = "Unconfigured"
if report:
report = "Enabled"
else:
report = "Disabled"

if timeout_channel:
timeout_channel = f"<#{timeout_channel}>"
else:
timeout_channel = "Unconfigured"

# Build embed
embed = discord.Embed(
Expand All @@ -261,20 +371,36 @@ async def timeoutset_list(self, ctx: commands.Context):
value=timeout_role,
inline=True
)
embed.add_field(
name="Timeout Channel",
value=timeout_channel,
inline=True
)

# Send embed
await ctx.send(embed=embed)

@commands.command()
@checks.mod()
async def timeout(self, ctx: commands.Context, user: discord.Member, *, reason="Unspecified"):
async def timeout(self, ctx: commands.Context, user: discord.Member, *, reason: str | None = None):
"""Timeouts a user or returns them from timeout if they are currently in timeout.

See and edit current configuration with `[p]timeoutset`.

Example:
Examples:
- `[p]timeout @user`
- `[p]timeout @user Spamming chat`

If the user is not already in timeout, their roles will be stored, stripped, and replaced with the timeout role.
If the user is already in timeout, they will be removed from the timeout role and have their former roles restored.

The cog determines that user is currently in timeout if the user's only role is the configured timeout role.
"""
# This isn't strictly necessary given the `@commands.guild_only()` decorator,
# but type checks still fail without this condition due to some wonky typing in discord.py
if ctx.guild is None:
raise TypeError("ctx.guild is None")

author = ctx.author
everyone_role = ctx.guild.default_role

Expand All @@ -286,17 +412,19 @@ async def timeout(self, ctx: commands.Context, user: discord.Member, *, reason="
timeout_role_data = await self.config.guild(ctx.guild).timeoutrole()
timeout_role = ctx.guild.get_role(timeout_role_data)

if await self.config.guild(ctx.guild).report() and not await self.config.guild(ctx.guild).logchannel():
await ctx.send("Please set the log channel using `[p]timeoutset logchannel`, or disable reporting.")
if timeout_role is None:
await ctx.send(f"Timeout role not found. Please set the timeout role using `{ctx.clean_prefix}timeoutset role`.")
return

# Notify and stop if command author tries to timeout themselves,
# or if the bot can't do that.
# another mod, or if the bot can't do that due to Discord role heirarchy.
if author == user:
await ctx.message.add_reaction("🚫")
await ctx.send("I cannot let you do that. Self-harm is bad \N{PENSIVE FACE}")
return

if ctx.guild.me.top_role <= user.top_role or user == ctx.guild.owner:
await ctx.message.add_reaction("🚫")
await ctx.send("I cannot do that due to Discord hierarchy rules.")
return

Expand All @@ -309,10 +437,22 @@ async def timeout(self, ctx: commands.Context, user: discord.Member, *, reason="
if booster_role in user.roles:
timeout_roleset.add(booster_role)

if await is_mod(ctx.bot, user):
await ctx.message.add_reaction("🚫")
await ctx.send("Nice try. I can't timeout other moderators or admins.")
return

# Assign reason string if not specified by user
if reason is None:
reason = "Unspecified"

# Check if user already in timeout.
# Remove & restore if so, else add to timeout.
if set(user.roles) == {everyone_role} | timeout_roleset:
await self.timeout_remove(ctx, user, reason)

else:
await self.timeout_add(ctx, user, reason, timeout_role, list(timeout_roleset))

# Run member data cleanup
await self.member_data_cleanup(ctx)
Loading