diff --git a/README.md b/README.md index 6027b5a5..6beeb537 100644 --- a/README.md +++ b/README.md @@ -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 [reason]` - Add/remove a user from timeout, optionally specifying a reason. - `[p]timeoutset list` - Print the current configuration. - `[p]timeoutset role ` - Set the timeout role. -- `[p]timeoutset report ` - Set whether timeout reports should be logged or not. +- `[p]timeoutset report ` - Set whether timeout reports should be logged or not. - `[p]timeoutset logchannel ` - Set the log channel. ### Topic diff --git a/timeout/timeout.py b/timeout/timeout.py index fff5fed6..d11c76e1 100644 --- a/timeout/timeout.py +++ b/timeout/timeout.py @@ -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") @@ -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", @@ -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. @@ -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.") @@ -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(): @@ -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(): @@ -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() @@ -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() @@ -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="") @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() @@ -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( @@ -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 @@ -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 @@ -309,6 +437,15 @@ 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: @@ -316,3 +453,6 @@ async def timeout(self, ctx: commands.Context, user: discord.Member, *, reason=" else: await self.timeout_add(ctx, user, reason, timeout_role, list(timeout_roleset)) + + # Run member data cleanup + await self.member_data_cleanup(ctx)