diff --git a/README.md b/README.md index 3b98f80..a8b733e 100644 --- a/README.md +++ b/README.md @@ -185,11 +185,13 @@ Points are awarded automatically per word when a member sends a message, and ded ## Moderation -### `/ban_member @target ` -- **Description**: Bans the target member. DMs them the reason before executing. Cannot ban bots or admins. +### `/ban_member @target [messages_to_remove] [delete_last_minutes]` +- **Description**: Bans the target member. DMs them the reason before executing. Cannot ban bots or admins. Optionally removes their recent messages, logging them to the mod log first. - **Arguments**: - `target`: The member to ban. - `reason`: The reason for the ban. + - `messages_to_remove`: Number of recent messages from this user to delete (optional, defaults to 0). + - `delete_last_minutes`: Delete everything this user sent in the last X minutes (optional, defaults to 0, max 10080 = 7 days). Only the newest 100 messages per channel are scanned for the mod log, so Discord may delete more than is logged. - **Permissions**: Moderator, master guild only, requires `ban_members` Discord permission. - **Output**: A public embedded message confirming who was banned and why. diff --git a/src/bot/cogs/_checks.py b/src/bot/cogs/_checks.py index 40a6d6e..60a23fa 100644 --- a/src/bot/cogs/_checks.py +++ b/src/bot/cogs/_checks.py @@ -1,7 +1,12 @@ +import logging import os +import discord +from discord import app_commands from discord.ext import commands +logger = logging.getLogger(__name__) + def is_admin(): async def predicate(ctx): @@ -23,3 +28,41 @@ async def predicate(ctx): return ctx.guild and ctx.guild.id == master_guild_id return commands.check(predicate) + + +def app_is_master_guild(): + async def predicate(interaction: discord.Interaction): + master_guild_id = int(os.getenv("MASTER_GUILD")) + return interaction.guild is not None and interaction.guild.id == master_guild_id + + return app_commands.check(predicate) + + +def app_requires_permissions(**permissions): + """ + Require guild permissions to run a slash command, and hide it by default + from members who lack them. + """ + + def decorator(func): + func = app_commands.checks.has_permissions(**permissions)(func) + func = app_commands.default_permissions(**permissions)(func) + return app_commands.guild_only()(func) + + return decorator + + +async def respond_to_app_command_error(interaction: discord.Interaction, error, action): + """ + Tell the user why a slash command was refused. `action` completes + "you don't have permission to ...". + """ + if not isinstance(error, app_commands.CheckFailure): + return + + logger.warning(f"{interaction.user} tried to {action} without permission.") + message = f"{interaction.user.mention}, you don't have permission to {action}." + if interaction.response.is_done(): + await interaction.followup.send(message, ephemeral=True) + else: + await interaction.response.send_message(message, ephemeral=True) diff --git a/src/bot/cogs/logging/logging_message_delete.py b/src/bot/cogs/logging/logging_message_delete.py index 47ca05a..a0d5823 100644 --- a/src/bot/cogs/logging/logging_message_delete.py +++ b/src/bot/cogs/logging/logging_message_delete.py @@ -11,11 +11,17 @@ from discord.ext import commands from src.bot.cogs import BaseCog +from src.bot.cogs.moderation._message_removal import ( + UNSAFE_FILENAME_CHARACTERS, + batch_posts, +) logger = logging.getLogger(__name__) -def embed_message_delete(some_member, some_message, some_moderator=None): +def embed_message_delete( + some_member, some_message, some_moderator=None, unrecovered=() +): """ Embedding for user message deletion alerts. """ @@ -45,7 +51,10 @@ def embed_message_delete(some_member, some_message, some_moderator=None): embed.add_field( name="Attachments: ", value="\n".join( - attachment.filename for attachment in some_message.attachments + f"{attachment.filename} *(could not be recovered)*" + if attachment.filename in unrecovered + else attachment.filename + for attachment in some_message.attachments )[0:1020], inline=False, ) @@ -78,22 +87,31 @@ def __init__(self, bot): if self.chat_log["status"] != "ok": raise RuntimeError("Failed to fetch chat log setting from API.") - async def build_image_file(self, attachment: discord.Attachment) -> discord.File: + async def read_deleted_attachment(self, attachment: discord.Attachment) -> bytes: """ - Return a File for the attachment, so it can be re-uploaded with the log embed. + Download an attachment whose message was just deleted. + + The direct CDN URL is purged immediately after a delete so we can try Discord's + media proxy first """ - data = await attachment.read() - return discord.File(BytesIO(data), filename=attachment.filename) + try: + return await attachment.read(use_cached=True) + except discord.HTTPException as err: + logger.debug( + f"Proxy fetch failed for {attachment.filename}, trying CDN -> {err}" + ) + return await attachment.read() - async def collect_image_files(self, message) -> list[discord.File]: + async def collect_image_files(self, message): """ Re-download every image attachment on the deleted message. - Discord purges the CDN copy shortly after a delete, so anything we - fail to fetch is simply left out of the log rather than raising. + Returns (files, unrecovered): (file, size) pairs for the images we got, + and the filenames of images we could not fetch or re-upload. """ size_limit = message.guild.filesize_limit files = [] + unrecovered = [] for attachment in message.attachments: if not ( attachment.content_type and attachment.content_type.startswith("image/") @@ -104,18 +122,46 @@ async def collect_image_files(self, message) -> list[discord.File]: f"Image attachment {attachment.filename} is too large to re-upload " f"({attachment.size} > {size_limit}). Skipping." ) + unrecovered.append(attachment.filename) continue logger.debug( f"Image attachment detected in deleted message, " f"{attachment.filename}:{attachment.url}" ) try: - files.append(await self.build_image_file(attachment)) + data = await self.read_deleted_attachment(attachment) except discord.HTTPException as err: logger.warning( f"Could not fetch deleted attachment {attachment.filename} -> {err}" ) - return files + unrecovered.append(attachment.filename) + continue + + # Prefixed so two images with the same name can't collide in one post. + safe_name = UNSAFE_FILENAME_CHARACTERS.sub("_", attachment.filename) + file = discord.File(BytesIO(data), filename=f"{len(files)}_{safe_name}") + files.append((file, len(data))) + return files, unrecovered + + async def send_log(self, logs_channel, embed, image_files, upload_limit): + """ + Send the log embed with the recovered images displayed inside embeds: + the first in the log embed itself, the rest in their own embeds. + """ + items = [(embed, None, 0)] + for index, (file, size) in enumerate(image_files): + image_embed = embed if index == 0 else discord.Embed(color=embed.color) + image_embed.set_image(url=f"attachment://{file.filename}") + if index == 0: + items[0] = (embed, file, size) + else: + items.append((image_embed, file, size)) + + for batch in batch_posts(items, upload_limit): + await logs_channel.send( + embeds=[item_embed for item_embed, _, _ in batch], + files=[file for _, file, _ in batch if file], + ) @commands.Cog.listener() async def on_message_delete(self, message) -> None: @@ -142,30 +188,34 @@ async def on_message_delete(self, message) -> None: logger.debug("Message from verification process was ignored.") return - audit_log = [entry async for entry in message.guild.audit_logs(limit=1)][0] if self.chat_log["status"] == "ok": if self.chat_log["log_setting"]["value"] == "0": logger.debug( f"log was triggered, but logging is disabled. API: {self.chat_log}" ) return + + image_files, unrecovered = await self.collect_image_files(message) + + audit_log = [entry async for entry in message.guild.audit_logs(limit=1)][0] logs_channel = await self.bot.fetch_channel( self.chat_log["log_setting"]["value"] ) - image_files = await self.collect_image_files(message) - if str(audit_log.action) == "AuditLogAction.message_delete": # Then a moderator deleted a message. - embed = embed_message_delete(audit_log.target, message, audit_log.user) - await logs_channel.send(embed=embed, files=image_files) - + embed = embed_message_delete( + audit_log.target, message, audit_log.user, unrecovered + ) else: # Otherwise, the author deleted it. - username = message.author - await logs_channel.send( - embed=embed_message_delete(username, message), files=image_files + embed = embed_message_delete( + message.author, message, unrecovered=unrecovered ) + + await self.send_log( + logs_channel, embed, image_files, message.guild.filesize_limit + ) else: logger.critical(f"API error. API response not ok. -> {self.chat_log}") diff --git a/src/bot/cogs/moderation/_message_removal.py b/src/bot/cogs/moderation/_message_removal.py new file mode 100644 index 0000000..7203dd5 --- /dev/null +++ b/src/bot/cogs/moderation/_message_removal.py @@ -0,0 +1,267 @@ +""" +Shared helpers for moderation commands that remove a member's recent messages. + +Messages should always written to the moderation log before they are deleted. +""" + +import asyncio +import datetime +import logging +import re +from collections import defaultdict +from io import BytesIO + +import discord + +logger = logging.getLogger(__name__) + +MAX_MESSAGES_TO_REMOVE = 100 # Discord's bulk delete limit +HISTORY_PER_CHANNEL = 100 # How far back each channel is searched. +BULK_DELETE_MAX_AGE = datetime.timedelta(days=14) # Discord refuses older bulk deletes. + +# Discord embed limits. +EMBED_DESCRIPTION_LIMIT = 4096 +EMBED_TOTAL_LIMIT = 6000 # Per embed, and across all embeds in one message. +EMBEDS_PER_MESSAGE = 10 +MESSAGE_PREVIEW_LIMIT = 1024 # Longest quote shown for any one removed message. +FILES_PER_MESSAGE = 10 +UNSAFE_FILENAME_CHARACTERS = re.compile(r"[^A-Za-z0-9._-]") + + +def _readable_channels(guild): + """ + Every text channel and active thread the bot can read history in. + """ + me = guild.me + for channel in [*guild.text_channels, *guild.threads]: + permissions = channel.permissions_for(me) + if permissions.view_channel and permissions.read_message_history: + yield channel + + +async def _channel_messages_by(channel, member): + try: + return [ + message + async for message in channel.history(limit=HISTORY_PER_CHANNEL) + if message.author.id == member.id + ] + except discord.HTTPException as err: + logger.warning(f"Could not read history in {channel} -> {err}") + return [] + + +async def find_recent_messages(guild, member, amount, since=None): + """ + Return the member's `amount` newest messages across all readable channels, + newest first. With `since`, every message sent at or after it is included too. + """ + if amount <= 0 and since is None: + return [] + + results = await asyncio.gather( + *( + _channel_messages_by(channel, member) + for channel in _readable_channels(guild) + ) + ) + messages = [message for channel_messages in results for message in channel_messages] + messages.sort(key=lambda message: message.created_at, reverse=True) + if since is not None: + in_window = sum(1 for message in messages if message.created_at >= since) + amount = max(amount, in_window) + return messages[:amount] + + +def _message_header(message): + # timestamps and <#channel> mentions only render in descriptions and + # field values; timestamps show in each viewer's own timezone. + sent = int(message.created_at.timestamp()) + return f"**<#{message.channel.id}>** · " + + +def _message_entry(message): + """ + One removed message, as a block of embed description markdown. + """ + header = _message_header(message) + + lines = (message.content or "*No text content*").splitlines() or [""] + lines += [f"📎 {attachment.filename}" for attachment in message.attachments] + quote = "\n".join(f"> {line}" for line in lines) + if len(quote) > MESSAGE_PREVIEW_LIMIT: + quote = quote[: MESSAGE_PREVIEW_LIMIT - 1] + "…" + return f"{header}\n{quote}" + + +def add_message_entries(embed, member, messages): + """ + Append a count and the removed messages, oldest first, to `embed`'s description. + Returns the embeds to send: `embed`, plus continuation embeds for whatever + would not fit within Discord's limits. + """ + embed.description = ( + f"{embed.description or ''}\n**Messages removed:** {len(messages)}".strip() + ) + embeds = [embed] + for message in reversed(messages): + entry = f"\n\n{_message_entry(message)}" + current = embeds[-1] + if ( + len(current.description) + len(entry) > EMBED_DESCRIPTION_LIMIT + or len(current) + len(entry) > EMBED_TOTAL_LIMIT + ): + current = discord.Embed( + title=f"Removed messages from {member} (continued)"[:256], + description="", + color=discord.Color.red(), + ) + embeds.append(current) + entry = entry.lstrip("\n") + current.description += entry + return embeds + + +async def collect_images(messages, upload_limit): + """ + Download every image attached to the messages, and wrap each + one in an embed that displays it. Must run before the messages are deleted: + Discord purges the CDN copy very quickly. + + Returns (embed, file, size) tuples for send_to_mod_log. + """ + images = [] + for message in reversed(messages): + for attachment in message.attachments: + if not (attachment.content_type or "").startswith("image/"): + continue + if attachment.size > upload_limit: + logger.info( + f"Image {attachment.filename} is too large to re-upload " + f"({attachment.size} > {upload_limit}). Skipping." + ) + continue + try: + data = await attachment.read() + except discord.HTTPException as err: + logger.warning(f"Could not fetch image {attachment.filename} -> {err}") + continue + + safe_name = UNSAFE_FILENAME_CHARACTERS.sub("_", attachment.filename) + file = discord.File(BytesIO(data), filename=f"{len(images)}_{safe_name}") + embed = discord.Embed( + description=f"{_message_header(message)}\n📎 {attachment.filename}"[ + :EMBED_DESCRIPTION_LIMIT + ], + color=discord.Color.red(), + ) + embed.set_image(url=f"attachment://{file.filename}") + images.append((embed, file, len(data))) + return images + + +def batch_posts(items, upload_limit): + """ + Group (embed, file, size) items into batches that fit in a single Discord message. + """ + batch, characters, uploaded = [], 0, 0 + for embed, file, size in items: + files_in_batch = sum(1 for _, batch_file, _ in batch if batch_file) + if batch and ( + len(batch) >= EMBEDS_PER_MESSAGE + or characters + len(embed) > EMBED_TOTAL_LIMIT + or (file and files_in_batch >= FILES_PER_MESSAGE) + or uploaded + size > upload_limit + ): + yield batch + batch, characters, uploaded = [], 0, 0 + batch.append((embed, file, size)) + characters += len(embed) + uploaded += size + if batch: + yield batch + + +async def send_to_mod_log(bot, *embeds, images=(), upload_limit=0): + """ + Post embeds to the moderation log. + `images` are (embed, file, size) tuples from collect_images. + + """ + mod_log_setting = bot.api.get_one_log_setting("5") # mod_log + if mod_log_setting["status"] != "ok": + logger.critical(f"API error. API response not ok. -> {mod_log_setting}") + return + if mod_log_setting["log_setting"]["value"] == "0": + logger.warning("Moderation log is disabled; removed messages were not logged.") + return + + try: + channel = await bot.fetch_channel(mod_log_setting["log_setting"]["value"]) + items = [(embed, None, 0) for embed in embeds] + list(images) + for batch in batch_posts(items, upload_limit): + await channel.send( + embeds=[embed for embed, _, _ in batch], + files=[file for _, file, _ in batch if file], + ) + except discord.HTTPException: + logger.exception("Failed to post to the moderation log.") + + +async def delete_messages(messages): + """ + Delete the messages, bulk-deleting per channel where Discord allows it. + Returns how many were deleted. + """ + by_channel = defaultdict(list) + for message in messages: + by_channel[message.channel].append(message) + + cutoff = discord.utils.utcnow() - BULK_DELETE_MAX_AGE + deleted = 0 + for channel, channel_messages in by_channel.items(): + recent = [ + message for message in channel_messages if message.created_at > cutoff + ] + old = [message for message in channel_messages if message.created_at <= cutoff] + + if len(recent) > 1: + try: + await channel.delete_messages(recent) + deleted += len(recent) + recent = [] + except discord.HTTPException as err: + logger.warning( + f"Bulk delete failed in {channel}, falling back -> {err}" + ) + + for message in [*recent, *old]: + try: + await message.delete() + deleted += 1 + except discord.HTTPException as err: + # NotFound and Forbidden are subclasses; keep deleting the rest. + logger.warning( + f"Could not delete message {message.id} in {channel} -> {err}" + ) + + return deleted + + +async def remove_and_log_messages(bot, guild, member, amount, embed): + """ + Find the member's last `amount` messages, log them to the moderation log + with `embed`, then delete them. Returns how many were deleted. + """ + messages = await find_recent_messages(guild, member, amount) + upload_limit = guild.filesize_limit + images = await collect_images(messages, upload_limit) + + await send_to_mod_log( + bot, + *add_message_entries(embed, member, messages), + images=images, + upload_limit=upload_limit, + ) + + return await delete_messages(messages) diff --git a/src/bot/cogs/moderation/admin_automod_spam_messages.py b/src/bot/cogs/moderation/admin_automod_spam_messages.py index a00ef8f..d325acd 100644 --- a/src/bot/cogs/moderation/admin_automod_spam_messages.py +++ b/src/bot/cogs/moderation/admin_automod_spam_messages.py @@ -13,6 +13,17 @@ DRAIN_SECONDS_PER_TOKEN = 60 * 20 # 1 token evaporated every 20 minutes WARN_THRESHOLD = 3 QUARANTINE_THRESHOLD = 4 +EMBED_FIELD_LIMIT = 1024 # Discord rejects embed field values longer than this +LOG_PREVIEW_LENGTH = 200 + + +def truncate(text, limit): + """ + Shortens text to fit within limit, marking the cut with an ellipsis. + """ + if len(text) <= limit: + return text + return text[: limit - 1] + "…" def embed_spammer_warn(channel1, channel2): @@ -51,9 +62,15 @@ def embed_spammer_quarantine(spammer, message_to_report=None, file_url=None): timestamp=datetime.datetime.now(datetime.timezone.utc), ) if message_to_report: - embed.add_field(name="Message:", value=message_to_report, inline=True) + embed.add_field( + name="Message:", + value=truncate(message_to_report, EMBED_FIELD_LIMIT), + inline=True, + ) if file_url: - embed.add_field(name="Image:", value=file_url, inline=True) + embed.add_field( + name="Image:", value=truncate(file_url, EMBED_FIELD_LIMIT), inline=True + ) return embed @@ -195,7 +212,7 @@ async def handle_repeated_messages(self, message, record): if count >= (QUARANTINE_THRESHOLD - error_rate) and record["stage"] < 3: record["stage"] = 3 logger.info( - f"{message.author.name} was quarantined (bucket >= {QUARANTINE_THRESHOLD}). Message: {message.content}" + f"{message.author.name} was quarantined (bucket >= {QUARANTINE_THRESHOLD}). Message: {message.content[:LOG_PREVIEW_LENGTH]}" ) await self.quarantine_user(message, record) @@ -203,7 +220,7 @@ async def handle_repeated_messages(self, message, record): elif count >= (WARN_THRESHOLD - error_rate) and record["stage"] < 2: record["stage"] = 2 logger.info( - f"{message.author.name} was timed out (bucket >= {WARN_THRESHOLD}). Message: {message.content}" + f"{message.author.name} was timed out (bucket >= {WARN_THRESHOLD}). Message: {message.content[:LOG_PREVIEW_LENGTH]}" ) await self.warn_user(message, record) @@ -274,16 +291,33 @@ async def quarantine_user(self, message, record): await message.author.remove_roles(verified_role) await message.author.add_roles(naughty_role) - await quarantine_channel.send( - embed=embed_spammer_quarantine( - message.author, message.content, record["messages"][-1]["file_url"] - ) - ) - + # Delete the spam before reporting, so a failed report can't leave it up. for msg in record["messages"]: - channel = await self.bot.fetch_channel(msg["channel_id"]) - msg_to_delete = await channel.fetch_message(msg["message_id"]) - await msg_to_delete.delete() + try: + channel = await self.bot.fetch_channel(msg["channel_id"]) + msg_to_delete = await channel.fetch_message(msg["message_id"]) + await msg_to_delete.delete() + except discord.errors.HTTPException as e: + # NotFound and Forbidden are subclasses; keep deleting the rest. + logger.warning( + "Could not delete spam message %s in channel %s: %s", + msg["message_id"], + msg["channel_id"], + e, + ) + + try: + await quarantine_channel.send( + embed=embed_spammer_quarantine( + message.author, + message.content, + record["messages"][-1]["file_url"], + ) + ) + except discord.errors.HTTPException: + logger.exception( + "Failed to post quarantine report for %s", message.author.name + ) finally: self.records[author_id] = { diff --git a/src/bot/cogs/moderation/admin_ban.py b/src/bot/cogs/moderation/admin_ban.py index 46557ba..cbdd348 100644 --- a/src/bot/cogs/moderation/admin_ban.py +++ b/src/bot/cogs/moderation/admin_ban.py @@ -7,13 +7,26 @@ import discord from discord import app_commands -from discord.ext import commands from src.bot.cogs import BaseCog -from src.bot.cogs._checks import is_master_guild, is_moderator +from src.bot.cogs._checks import ( + app_is_master_guild, + app_requires_permissions, + respond_to_app_command_error, +) +from src.bot.cogs.moderation._message_removal import ( + MAX_MESSAGES_TO_REMOVE, + add_message_entries, + collect_images, + delete_messages, + find_recent_messages, + send_to_mod_log, +) logger = logging.getLogger(__name__) +MAX_DELETE_MINUTES = 7 * 24 * 60 # Discord's delete_message_seconds limit + def embed_info(message): """ @@ -38,12 +51,16 @@ def __init__(self, bot): self.bot = bot - @is_moderator() - @is_master_guild() - @commands.has_permissions(ban_members=True) @app_commands.command() + @app_is_master_guild() + @app_requires_permissions(ban_members=True) async def ban_member( - self, interaction: discord.Interaction, target: discord.Member, reason: str + self, + interaction: discord.Interaction, + target: discord.Member, + reason: str, + messages_to_remove: app_commands.Range[int, 0, MAX_MESSAGES_TO_REMOVE] = 0, + delete_last_minutes: app_commands.Range[int, 0, MAX_DELETE_MINUTES] = 0, ): """ Moderation command to ban a member from the server. @@ -54,6 +71,10 @@ async def ban_member( The member that needs to be banned. reason : str The reason for the ban. + messages_to_remove : int + How many messages to delete and log. Optional, defaults to 0. + delete_last_minutes : int + Delete everything they sent in the last X minutes. Optional, defaults to 0. """ # Cant ban bots or admins. if not target.bot: @@ -61,20 +82,57 @@ async def ban_member( # Message the user, informing them of their fate # TODO: Guild specific settings like the contact email await interaction.response.defer() - await target.send( - f"## You were banned by {interaction.user.name}.\n" - f"**Reason:** {reason}\n" - "\nIf you wish to appeal this ban," - " contact PracticalPythonStaff@gmail.com" + try: + await target.send( + f"## You were banned by {interaction.user.name}.\n" + f"**Reason:** {reason}\n" + "\nIf you wish to appeal this ban," + " contact PracticalPythonStaff@gmail.com" + ) + except discord.HTTPException: + # Closed DMs must not stop the ban. + logger.info("Could not DM %s about their ban.", target.name) + since = None + if delete_last_minutes: + since = discord.utils.utcnow() - datetime.timedelta( + minutes=delete_last_minutes + ) + messages = await find_recent_messages( + interaction.guild, target, messages_to_remove, since=since + ) + upload_limit = interaction.guild.filesize_limit + images = await collect_images(messages, upload_limit) + # Then we do dat ban + await target.ban( + reason=f"{interaction.user.name} - {reason}", + delete_message_seconds=delete_last_minutes * 60, ) - # Then we do the ban - await target.ban(reason=f"{interaction.user.name} - {reason}") logger.info( "{%s} banned {%s}. Reason: {%s}", interaction.user.name, target.name, reason, ) + await send_to_mod_log( + self.bot, + *add_message_entries( + embed_info( + f"{interaction.user.mention} banned {target.mention}" + f"\n**Reason:** {reason}" + ), + target, + messages, + ), + images=images, + upload_limit=upload_limit, + ) + await delete_messages( + [ + message + for message in messages + if since is None or message.created_at < since + ] + ) # Then we publicly announce what happened. await interaction.followup.send( embed=embed_info( @@ -90,20 +148,8 @@ async def ban_member( await interaction.channel.send(embed=embed_info("You cant ban a bot.")) @ban_member.error - async def ban_error(self, ctx, error): - if isinstance(error, commands.MemberNotFound): - await ctx.channel.send( - embed=embed_info( - "User was not found, please check the name and use a mention." - ) - ) - - if isinstance(error, commands.CheckFailure): - await ctx.channel.send( - embed=embed_info( - f"{ctx.author.mention}, you dont have permission to ban users. The staff has been notified." - ) - ) + async def ban_error(self, interaction: discord.Interaction, error): + await respond_to_app_command_error(interaction, error, "ban users") async def setup(bot) -> None: diff --git a/src/bot/cogs/moderation/admin_kick.py b/src/bot/cogs/moderation/admin_kick.py index 0c76edd..a7e30c3 100644 --- a/src/bot/cogs/moderation/admin_kick.py +++ b/src/bot/cogs/moderation/admin_kick.py @@ -7,10 +7,13 @@ import discord from discord import app_commands -from discord.ext import commands from src.bot.cogs import BaseCog -from src.bot.cogs._checks import is_master_guild, is_moderator +from src.bot.cogs._checks import ( + app_is_master_guild, + app_requires_permissions, + respond_to_app_command_error, +) logger = logging.getLogger(__name__) @@ -38,10 +41,9 @@ def __init__(self, bot): self.bot = bot - @is_moderator() - @is_master_guild() @app_commands.command() - @commands.has_permissions(kick_members=True) + @app_is_master_guild() + @app_requires_permissions(ban_members=True, kick_members=True) async def kick_member( self, interaction: discord.Interaction, target: discord.Member, reason: str ): @@ -90,20 +92,8 @@ async def kick_member( await interaction.channel.send(embed=embed_info("You cant kick a bot.")) @kick_member.error - async def kick_error(self, ctx, error): - if isinstance(error, commands.MemberNotFound): - await ctx.channel.send( - embed=embed_info( - "User was not found, please check the name and use a mention." - ) - ) - - if isinstance(error, commands.CheckFailure): - await ctx.channel.send( - embed=embed_info( - f"{ctx.author.mention}, you dont have permission to kick users. The staff has been notified." - ) - ) + async def kick_error(self, interaction: discord.Interaction, error): + await respond_to_app_command_error(interaction, error, "kick users") async def setup(bot) -> None: diff --git a/src/bot/cogs/moderation/admin_mute.py b/src/bot/cogs/moderation/admin_mute.py index b1b76e2..b89c133 100644 --- a/src/bot/cogs/moderation/admin_mute.py +++ b/src/bot/cogs/moderation/admin_mute.py @@ -7,10 +7,13 @@ import discord from discord import app_commands -from discord.ext import commands from src.bot.cogs import BaseCog -from src.bot.cogs._checks import is_master_guild, is_moderator +from src.bot.cogs._checks import ( + app_is_master_guild, + app_requires_permissions, + respond_to_app_command_error, +) logger = logging.getLogger(__name__) @@ -38,10 +41,9 @@ def __init__(self, bot): self.bot = bot - @is_moderator() - @is_master_guild() @app_commands.command() - @commands.has_permissions(moderate_members=True) + @app_is_master_guild() + @app_requires_permissions(ban_members=True, moderate_members=True) async def mute_member( self, interaction: discord.Interaction, @@ -99,20 +101,8 @@ async def mute_member( await interaction.channel.send(embed=embed_info("You cant mute a bot.")) @mute_member.error - async def mute_error(self, ctx, error): - if isinstance(error, commands.MemberNotFound): - await ctx.channel.send( - embed=embed_info( - "User was not found, please check the name and use a mention." - ) - ) - - if isinstance(error, commands.CheckFailure): - await ctx.channel.send( - embed=embed_info( - f"{ctx.author.mention}, you dont have permission to mute users. The staff has been notified." - ) - ) + async def mute_error(self, interaction: discord.Interaction, error): + await respond_to_app_command_error(interaction, error, "mute users") async def setup(bot) -> None: diff --git a/src/bot/cogs/moderation/admin_purge.py b/src/bot/cogs/moderation/admin_purge.py index 2be4cb5..56a209b 100644 --- a/src/bot/cogs/moderation/admin_purge.py +++ b/src/bot/cogs/moderation/admin_purge.py @@ -7,10 +7,13 @@ import discord from discord import app_commands -from discord.ext import commands from src.bot.cogs import BaseCog -from src.bot.cogs._checks import is_master_guild, is_moderator +from src.bot.cogs._checks import ( + app_is_master_guild, + app_requires_permissions, + respond_to_app_command_error, +) logger = logging.getLogger(__name__) @@ -52,9 +55,9 @@ def __init__(self, bot): self.bot = bot self.log_channel_req = self.bot.api.get_one_log_setting("3") # chat_log - @is_moderator() - @is_master_guild() @app_commands.command() + @app_is_master_guild() + @app_requires_permissions(ban_members=True) async def purge_messages(self, interaction: discord.Interaction, amount: int): """ Purge a set of messages from the current channel. @@ -93,13 +96,8 @@ async def purge_messages(self, interaction: discord.Interaction, amount: int): logger.critical("API error while purging messages. Status is NOT ok.") @purge_messages.error - async def purge_error(self, ctx, error): - if isinstance(error, commands.CheckFailure): - await ctx.channel.send( - embed=embed_info( - f"{ctx.author.mention}, you dont have permission to purge messages. The staff has been notified." - ) - ) + async def purge_error(self, interaction: discord.Interaction, error): + await respond_to_app_command_error(interaction, error, "purge messages") async def setup(bot) -> None: diff --git a/src/bot/cogs/moderation/admin_quarantine.py b/src/bot/cogs/moderation/admin_quarantine.py index 93daa21..2bb130e 100644 --- a/src/bot/cogs/moderation/admin_quarantine.py +++ b/src/bot/cogs/moderation/admin_quarantine.py @@ -2,17 +2,24 @@ Admin command for kicking a user. """ -import asyncio import datetime import logging import discord from discord import app_commands -from discord.ext import commands from discord.utils import get from src.bot.cogs import BaseCog -from src.bot.cogs._checks import is_master_guild, is_moderator +from src.bot.cogs._checks import ( + app_is_master_guild, + app_requires_permissions, + respond_to_app_command_error, +) +from src.bot.cogs.moderation._message_removal import ( + MAX_MESSAGES_TO_REMOVE, + remove_and_log_messages, + send_to_mod_log, +) logger = logging.getLogger(__name__) @@ -43,9 +50,9 @@ def embed_cant_do_that(message): return embed -def embed_quarantine(moderator, some_member, number_of_removed_messages): +def embed_quarantine(moderator, some_member): """ - Embedding for user ban alerts. + Embedding for user quarantine alerts. """ embed = discord.Embed( title="", @@ -53,13 +60,6 @@ def embed_quarantine(moderator, some_member, number_of_removed_messages): color=discord.Color.red(), timestamp=datetime.datetime.now(datetime.timezone.utc), ) - - if number_of_removed_messages > 0: - embed.add_field( - name="Messages removed:", - value=f"{str(number_of_removed_messages)} messages.", - ) - return embed @@ -72,23 +72,25 @@ def __init__(self, bot): super().__init__(logger) self.bot = bot - self.naughty_role = self.bot.api.get_one_role("7")["role"][ - "value" - ] # quarantine role ID - self.verified_role = self.bot.api.get_one_role("6")["role"][ - "value" - ] # Verification role ID - self.mod_log = self.bot.api.get_one_log_setting("5") # mod_log + + def find_role(self, guild, role_flag): + """ + Look up a configured role in the guild. None if unset, or if it doesn't exist here. + """ + response = self.bot.api.get_one_role(role_flag) + if response["status"] != "ok": + logger.critical(f"API error. API response not ok. -> {response}") + return None + return get(guild.roles, id=int(response["role"]["value"])) @app_commands.command() - @is_moderator() - @is_master_guild() - @commands.has_permissions(moderate_members=True) + @app_is_master_guild() + @app_requires_permissions(ban_members=True, moderate_members=True) async def quarantine( self, interaction: discord.Interaction, target: discord.Member, - messages_to_remove: int, + messages_to_remove: app_commands.Range[int, 0, MAX_MESSAGES_TO_REMOVE], ): """ Quarantines the specified user. Removes their access from public channels. @@ -98,7 +100,7 @@ async def quarantine( target : discord.Member The member to quarantine. messages_to_remove : int - The number of messages to remove from this member. + How many messages to delete and log. """ await interaction.response.defer() @@ -107,45 +109,51 @@ async def quarantine( ) if not target.bot: if not target.guild_permissions.administrator: - message_counter = 0 - - mod_log = await self.bot.fetch_channel( - self.mod_log["log_setting"]["value"] - ) - verified_role = get(interaction.guild.roles, id=int(self.verified_role)) - naughty_role = get(interaction.guild.roles, id=int(self.naughty_role)) - - try: - await target.remove_roles(verified_role) - await target.add_roles(naughty_role) - await interaction.followup.send( - f"{target.name} has been quarantined.", ephemeral=True + # Read on every use so changes made with /settings apply without a restart. + verified_role = self.find_role(interaction.guild, "6") + naughty_role = self.find_role(interaction.guild, "7") + missing = [ + name + for name, role in ( + ("verified (role 6)", verified_role), + ("quarantine (role 7)", naughty_role), ) + if role is None + ] - except Exception as notification1: - await interaction.followup.send( - "There was an issue with the command.", ephemeral=True - ) - logger.critical( - f"There was an error in the Quarantine command...\n{notification1}" + if missing: + logger.error( + f"Quarantine failed: {', '.join(missing)} not found in {interaction.guild.name}." ) - - # remove messages, if that was specified. await interaction.followup.send( - content=f"Removing {messages_to_remove} messages by {target.name}...", + embed=embed_cant_do_that( + f"{target.name} was **not** quarantined: the {' and '.join(missing)}" + " setting does not match a role in this server. Fix it with /settings." + ), ephemeral=True, ) - - if messages_to_remove > 0: - async for message in interaction.channel.history(limit=50): - if messages_to_remove > message_counter: - if message.author.name == target.name: - await message.delete() - message_counter += 1 - await asyncio.sleep(0.2) # Avoiding rate limits. - - await mod_log.send( - embed=embed_quarantine(interaction.user, target, message_counter) + else: + try: + await target.remove_roles(verified_role) + await target.add_roles(naughty_role) + await interaction.followup.send( + f"{target.name} has been quarantined.", ephemeral=True + ) + + except Exception as notification1: + await interaction.followup.send( + "There was an issue with the command.", ephemeral=True + ) + logger.critical( + f"There was an error in the Quarantine command...\n{notification1}" + ) + + await remove_and_log_messages( + self.bot, + interaction.guild, + target, + messages_to_remove, + embed_quarantine(interaction.user, target), ) else: @@ -159,9 +167,8 @@ async def quarantine( ) @app_commands.command() - @commands.has_permissions(moderate_members=True) - @is_moderator() - @is_master_guild() + @app_is_master_guild() + @app_requires_permissions(ban_members=True, moderate_members=True) async def release(self, interaction: discord.Interaction, target: discord.Member): """ Releases the specified member from quarantine. Gives back their access to public channels. @@ -177,9 +184,18 @@ async def release(self, interaction: discord.Interaction, target: discord.Member f"{interaction.user.name} used the release command on {target.name}" ) if not target.bot: - mod_log = await self.bot.fetch_channel(self.mod_log["log_setting"]["value"]) - verified_role = get(interaction.guild.roles, id=int(self.verified_role)) - naughty_role = get(interaction.guild.roles, id=int(self.naughty_role)) + verified_role = self.find_role(interaction.guild, "6") + naughty_role = self.find_role(interaction.guild, "7") + if verified_role is None or naughty_role is None: + await interaction.followup.send( + embed=embed_cant_do_that( + f"{target.name} was **not** released: the verified (role 6) or" + " quarantine (role 7) setting does not match a role in this server." + " Fix it with /settings." + ), + ephemeral=True, + ) + return try: await target.add_roles(verified_role) @@ -188,10 +204,11 @@ async def release(self, interaction: discord.Interaction, target: discord.Member f"{interaction.user.mention} released {target.mention} from quarantine", ephemeral=True, ) - await mod_log.send( - embed=embed_info( + await send_to_mod_log( + self.bot, + embed_info( f"{interaction.user.mention} released {target.mention} from quarantine" - ) + ), ) except Exception as notification1: @@ -203,20 +220,14 @@ async def release(self, interaction: discord.Interaction, target: discord.Member ) @quarantine.error - async def kick_error(self, ctx, error): - if isinstance(error, commands.MemberNotFound): - await ctx.channel.send( - embed=embed_info( - "User was not found, please check the name and use a mention." - ) - ) + async def quarantine_error(self, interaction: discord.Interaction, error): + await respond_to_app_command_error(interaction, error, "quarantine users") - if isinstance(error, commands.CheckFailure): - await ctx.channel.send( - embed=embed_info( - f"{ctx.author.mention}, you dont have permission to kick users. The staff has been notified." - ) - ) + @release.error + async def release_error(self, interaction: discord.Interaction, error): + await respond_to_app_command_error( + interaction, error, "release users from quarantine" + ) async def setup(bot) -> None: diff --git a/src/bot/cogs/verification/verification_dropdown.py b/src/bot/cogs/verification/verification_dropdown.py index ebd2db3..c8d2c84 100644 --- a/src/bot/cogs/verification/verification_dropdown.py +++ b/src/bot/cogs/verification/verification_dropdown.py @@ -8,11 +8,19 @@ import datetime import logging +from typing import Literal import discord +from discord import app_commands from discord.ext import commands from src.bot.cogs import BaseCog +from src.bot.cogs._checks import ( + app_is_master_guild, + app_requires_permissions, + respond_to_app_command_error, +) +from src.bot.cogs.moderation._message_removal import send_to_mod_log logger = logging.getLogger(__name__) @@ -31,6 +39,17 @@ def embed_verified_success(name, amount): return embed +VERIFICATION_PAUSED_MESSAGE = ( + "Verification is temporarily paused. Please try again later." +) +VERIFICATION_PARAMETER = "verification_enabled" + + +def verification_is_enabled(bot): + cog = bot.get_cog("Verification") + return cog is None or cog.enabled + + class VerificationSelector(discord.ui.Select): def __init__(self, bot): self.bot = bot @@ -74,6 +93,13 @@ async def callback(self, interaction: discord.Interaction): verification_log = self.bot.get_channel(int(self.verification_log)) if self.values[0] == "not_robot": + # The dropdown may have been opened before verification was paused. + if not verification_is_enabled(self.bot): + await interaction.response.send_message( + VERIFICATION_PAUSED_MESSAGE, ephemeral=True + ) + return + you_win = interaction.guild.get_role(int(self.verified_role)) join_log = await self.bot.fetch_channel(self.join_log) try: @@ -126,6 +152,80 @@ def __init__(self, bot): "value" ] self.verified_role = self.bot.api.get_one_role("6")["role"]["value"] + self.enabled = self.load_enabled() + + def load_enabled(self): + """ + Read the stored verification switch. If the API can't answer, stay + enabled so an outage doesn't lock every new member out. + """ + response = self.bot.api.get_parameter(VERIFICATION_PARAMETER) + if response["status"] != "ok": + logger.critical( + f"Could not read {VERIFICATION_PARAMETER}; verification stays enabled." + f" API: {response}" + ) + return True + + enabled = response["parameter"] != "0" + if not enabled: + logger.warning("Verification is paused (restored from the database).") + return enabled + + @app_commands.command() + @app_is_master_guild() + @app_requires_permissions(ban_members=True) + async def verification( + self, + interaction: discord.Interaction, + action: Literal["enable", "disable", "status"], + ): + """ + Pause or resume verification, to stop new members joining the main server. + + Parameters + ---------- + action : str + disable pauses verification, enable resumes it, status shows the current state. + """ + if action == "status": + state = "enabled" if self.enabled else "**paused**" + await interaction.response.send_message( + f"Verification is {state}.", ephemeral=True + ) + return + + enabled = action == "enable" + # Apply immediately, even if saving fails, so a raid can be stopped now. + self.enabled = enabled + response = self.bot.api.set_parameter( + VERIFICATION_PARAMETER, "1" if enabled else "0" + ) + saved = response["status"] == "ok" + + state = "enabled" if enabled else "paused" + logger.warning(f"{interaction.user.name} {state} verification.") + reply = f"Verification is now {'enabled' if enabled else '**paused**'}." + if not saved: + logger.critical(f"Could not save {VERIFICATION_PARAMETER}. API: {response}") + reply += ( + "\n⚠️ The setting could not be saved, so it will reset to enabled" + " if the bot restarts." + ) + await interaction.response.send_message(reply, ephemeral=True) + + await send_to_mod_log( + self.bot, + discord.Embed( + description=f"{interaction.user.mention} {state} verification.", + color=discord.Color.green() if enabled else discord.Color.red(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ), + ) + + @verification.error + async def verification_error(self, interaction: discord.Interaction, error): + await respond_to_app_command_error(interaction, error, "change verification") @commands.command() async def verify(self, ctx): @@ -139,6 +239,10 @@ async def verify(self, ctx): ) return + elif not self.enabled: + logger.info(f"{ctx.author.name} tried to verify while it is paused.") + await ctx.send(VERIFICATION_PAUSED_MESSAGE, delete_after=15.0) + else: if int(self.verified_role) not in [role.id for role in ctx.author.roles]: logger.debug(f"{ctx.author.name} is attempting to verify") diff --git a/src/db/init.sql b/src/db/init.sql index e07eb6d..799d8de 100644 --- a/src/db/init.sql +++ b/src/db/init.sql @@ -67,4 +67,5 @@ CREATE TABLE IF NOT EXISTS parameters ( ); INSERT INTO parameters (parameter_name, parameter_value) VALUES - ('monthly_yapper', '0'); + ('monthly_yapper', '0'), + ('verification_enabled', '1'); diff --git a/src/db/migrations.sql b/src/db/migrations.sql index 309c506..d944519 100644 --- a/src/db/migrations.sql +++ b/src/db/migrations.sql @@ -31,4 +31,11 @@ BEGIN; WHERE parameter_name='monthly_yapper' ); + INSERT INTO parameters (parameter_name, parameter_value) + SELECT 'verification_enabled', '1' + WHERE NOT EXISTS ( + SELECT * FROM parameters + WHERE parameter_name='verification_enabled' + ); + COMMIT;