From 8010dc0ac9b8b9e140b179e685c462e9e79abb2b Mon Sep 17 00:00:00 2001 From: reichbc Date: Tue, 11 Aug 2026 01:41:54 -0400 Subject: [PATCH 1/5] add labguard --- labguard/__init__.py | 5 ++ labguard/info.json | 11 +++ labguard/labguard.py | 162 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 labguard/__init__.py create mode 100644 labguard/info.json create mode 100644 labguard/labguard.py diff --git a/labguard/__init__.py b/labguard/__init__.py new file mode 100644 index 00000000..be3e6ee7 --- /dev/null +++ b/labguard/__init__.py @@ -0,0 +1,5 @@ +from .labguard import LabGuard + + +async def setup(bot): + await bot.add_cog(LabGuard(bot)) \ No newline at end of file diff --git a/labguard/info.json b/labguard/info.json new file mode 100644 index 00000000..5048d85d --- /dev/null +++ b/labguard/info.json @@ -0,0 +1,11 @@ +{ + "author": ["Ramstik"], + "min_bot_version": "3.5.0", + "description": "Kicks users for posting in a restricted channel or acquiring a restricted role.", + "short": "Auto-kick on restricted channel post or role grant.", + "hidden": false, + "disabled": false, + "install_msg": "LabGuard loaded. Use /labguard settings to configure.", + "end_user_data_statement": "This cog stores channel and role IDs per guild for configuration purposes only. No user message content is stored.", + "tags": ["moderation", "security", "kick"] +} \ No newline at end of file diff --git a/labguard/labguard.py b/labguard/labguard.py new file mode 100644 index 00000000..b796a725 --- /dev/null +++ b/labguard/labguard.py @@ -0,0 +1,162 @@ +from typing import Literal + +import discord +from discord import app_commands +from redbot.core import commands, Config +from redbot.core.bot import Red + + +# Replace with your Discord user ID. This user always passes the permission check. +# Only used in the event that an emergency change is needed. +OWNER_OVERRIDE_ID = 313542301022552074 + + +class LabGuardGroup(app_commands.Group): + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id == OWNER_OVERRIDE_ID: + return True + if interaction.user.guild_permissions.manage_guild: + return True + await interaction.response.send_message( + "You do not have permission to use this command.", ephemeral=True + ) + return False + + +labguard_group = LabGuardGroup( + name="labguard", + description="Configure LabGuard", + default_permissions=discord.Permissions(manage_guild=True), + guild_only=True, +) +exempt_group = app_commands.Group( + name="exempt", + description="Manage roles exempt from the channel trigger", + parent=labguard_group, +) + + +class LabGuard(commands.Cog): + """Kicks users for posting in a restricted channel or acquiring a restricted role.""" + + def __init__(self, bot: Red): + self.bot = bot + self.config = Config.get_conf(self, identifier=0x4C414247, force_registration=True) + self.config.register_guild( + trigger_channel=None, + log_channel=None, + trigger_role=None, + exempt_roles=[], + ) + + async def _kick(self, member: discord.Member, reason: str): + guild = member.guild + try: + await member.kick(reason=reason) + result = f"Kicked {member} (`{member.id}`) — {reason}" + color = discord.Color.red() + except discord.Forbidden: + result = f"Failed to kick {member} (`{member.id}`) — missing permissions" + color = discord.Color.orange() + except discord.HTTPException as e: + result = f"Failed to kick {member} (`{member.id}`) — {e}" + color = discord.Color.orange() + + log_channel_id = await self.config.guild(guild).log_channel() + if not log_channel_id: + return + log_channel = guild.get_channel(log_channel_id) + if log_channel: + try: + await log_channel.send(embed=discord.Embed(description=result, color=color)) + except discord.HTTPException: + pass + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + if not message.guild or message.author.bot: + return + guild_conf = self.config.guild(message.guild) + trigger_channel = await guild_conf.trigger_channel() + if not trigger_channel or message.channel.id != trigger_channel: + return + exempt_roles = await guild_conf.exempt_roles() + author_role_ids = {r.id for r in message.author.roles} + if author_role_ids.intersection(exempt_roles): + return + await self._kick(message.author, f"Posted in restricted channel {message.channel.mention}") + + @commands.Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member): + trigger_role = await self.config.guild(after.guild).trigger_role() + if not trigger_role: + return + before_ids = {r.id for r in before.roles} + after_ids = {r.id for r in after.roles} + if trigger_role in after_ids and trigger_role not in before_ids: + await self._kick(after, f"Acquired restricted role <@&{trigger_role}>") + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + trigger_role = await self.config.guild(member.guild).trigger_role() + if trigger_role and trigger_role in {r.id for r in member.roles}: + await self._kick(member, f"Joined already possessing restricted role <@&{trigger_role}>") + + @labguard_group.command(name="settings", description="View current LabGuard configuration") + async def settings_cmd(self, interaction: discord.Interaction): + conf = await self.config.guild(interaction.guild).all() + trigger_channel = interaction.guild.get_channel(conf["trigger_channel"]) if conf["trigger_channel"] else None + log_channel = interaction.guild.get_channel(conf["log_channel"]) if conf["log_channel"] else None + trigger_role = interaction.guild.get_role(conf["trigger_role"]) if conf["trigger_role"] else None + exempt_roles = [interaction.guild.get_role(r) for r in conf["exempt_roles"]] + exempt_roles = [r.mention for r in exempt_roles if r] + + embed = discord.Embed(title="LabGuard Settings", color=discord.Color.blurple()) + embed.add_field(name="Trigger Channel", value=trigger_channel.mention if trigger_channel else "Not set", inline=False) + embed.add_field(name="Trigger Role", value=trigger_role.mention if trigger_role else "Not set", inline=False) + embed.add_field(name="Log Channel", value=log_channel.mention if log_channel else "Not set", inline=False) + embed.add_field(name="Exempt Roles", value=", ".join(exempt_roles) if exempt_roles else "None", inline=False) + await interaction.response.send_message(embed=embed, ephemeral=True) + + @labguard_group.command(name="channel", description="Set the channel that triggers a kick on any message") + @app_commands.describe(channel="Channel to monitor") + async def channel_cmd(self, interaction: discord.Interaction, channel: discord.TextChannel): + await self.config.guild(interaction.guild).trigger_channel.set(channel.id) + await interaction.response.send_message(f"Trigger channel set to {channel.mention}", ephemeral=True) + + @labguard_group.command(name="role", description="Set the role that triggers a kick when acquired") + @app_commands.describe(role="Role to monitor") + async def role_cmd(self, interaction: discord.Interaction, role: discord.Role): + await self.config.guild(interaction.guild).trigger_role.set(role.id) + await interaction.response.send_message(f"Trigger role set to {role.mention}", ephemeral=True) + + @labguard_group.command(name="logchannel", description="Set the channel where kick logs are posted") + @app_commands.describe(channel="Channel to post kick logs in") + async def logchannel_cmd(self, interaction: discord.Interaction, channel: discord.TextChannel): + await self.config.guild(interaction.guild).log_channel.set(channel.id) + await interaction.response.send_message(f"Log channel set to {channel.mention}", ephemeral=True) + + @labguard_group.command(name="disable", description="Clear the trigger channel or trigger role") + @app_commands.describe(target="Which trigger to disable") + async def disable_cmd(self, interaction: discord.Interaction, target: Literal["channel", "role"]): + if target == "channel": + await self.config.guild(interaction.guild).trigger_channel.set(None) + else: + await self.config.guild(interaction.guild).trigger_role.set(None) + await interaction.response.send_message(f"Trigger {target} cleared", ephemeral=True) + + @exempt_group.command(name="add", description="Exempt a role from the channel trigger") + @app_commands.describe(role="Role to exempt") + async def exempt_add(self, interaction: discord.Interaction, role: discord.Role): + async with self.config.guild(interaction.guild).exempt_roles() as roles: + if role.id not in roles: + roles.append(role.id) + await interaction.response.send_message(f"{role.mention} is now exempt", ephemeral=True) + + @exempt_group.command(name="remove", description="Remove a role's exemption from the channel trigger") + @app_commands.describe(role="Role to remove exemption from") + async def exempt_remove(self, interaction: discord.Interaction, role: discord.Role): + async with self.config.guild(interaction.guild).exempt_roles() as roles: + if role.id in roles: + roles.remove(role.id) + await interaction.response.send_message(f"{role.mention} is no longer exempt", ephemeral=True) \ No newline at end of file From d4f7a95974b5ce29db79d6346f05196b51a3b1ea Mon Sep 17 00:00:00 2001 From: reichbc Date: Tue, 11 Aug 2026 01:47:41 -0400 Subject: [PATCH 2/5] append readme for labguard --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 59e6eebd..c0252ba5 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Cogs for the [Red-DiscordBot](https://github.com/Cog-Creators/Red-DiscordBot/)-b - [IsItReadOnlyFriday](#isitreadonlyfriday) - [Jail](#jail) - [LaTeX](#latex) + - [LabGuard](#labguard) - [Letters](#letters) - [Markov](#markov) - [User commands](#user-commands) @@ -256,6 +257,13 @@ This cog allows users to display complex mathematical information using LaTeX re `[p]latex ` +### LabGuard + +This cog automatically kicks users for posting in a specific channel, or after acquiring a specific role. +Used to help fight spam bots and bot accounts. + +`/labguard ` + ### Letters This cog converts a string of letters/numbers into large emote letters ("regional indicators") or numbers. From 87009f28c6ec8f004a88e7a8c69f3e43df17bfa7 Mon Sep 17 00:00:00 2001 From: reichbc Date: Tue, 11 Aug 2026 02:02:50 -0400 Subject: [PATCH 3/5] switch kick to ban for channel; cleans up messages --- labguard/labguard.py | 53 +++++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/labguard/labguard.py b/labguard/labguard.py index b796a725..95cee457 100644 --- a/labguard/labguard.py +++ b/labguard/labguard.py @@ -37,7 +37,7 @@ async def interaction_check(self, interaction: discord.Interaction) -> bool: class LabGuard(commands.Cog): - """Kicks users for posting in a restricted channel or acquiring a restricted role.""" + """Kicks users for acquiring a restricted role; bans (with message purge) for posting in a restricted channel.""" def __init__(self, bot: Red): self.bot = bot @@ -47,10 +47,21 @@ def __init__(self, bot: Red): log_channel=None, trigger_role=None, exempt_roles=[], + ban_purge_seconds=86400, ) + async def _log(self, guild: discord.Guild, description: str, color: discord.Color): + log_channel_id = await self.config.guild(guild).log_channel() + if not log_channel_id: + return + log_channel = guild.get_channel(log_channel_id) + if log_channel: + try: + await log_channel.send(embed=discord.Embed(description=description, color=color)) + except discord.HTTPException: + pass + async def _kick(self, member: discord.Member, reason: str): - guild = member.guild try: await member.kick(reason=reason) result = f"Kicked {member} (`{member.id}`) — {reason}" @@ -61,16 +72,20 @@ async def _kick(self, member: discord.Member, reason: str): except discord.HTTPException as e: result = f"Failed to kick {member} (`{member.id}`) — {e}" color = discord.Color.orange() + await self._log(member.guild, result, color) - log_channel_id = await self.config.guild(guild).log_channel() - if not log_channel_id: - return - log_channel = guild.get_channel(log_channel_id) - if log_channel: - try: - await log_channel.send(embed=discord.Embed(description=result, color=color)) - except discord.HTTPException: - pass + async def _ban(self, member: discord.Member, reason: str, purge_seconds: int): + try: + await member.ban(reason=reason, delete_message_seconds=purge_seconds) + result = f"Banned {member} (`{member.id}`) — {reason} (purged last {purge_seconds // 3600}h of messages)" + color = discord.Color.dark_red() + except discord.Forbidden: + result = f"Failed to ban {member} (`{member.id}`) — missing permissions" + color = discord.Color.orange() + except discord.HTTPException as e: + result = f"Failed to ban {member} (`{member.id}`) — {e}" + color = discord.Color.orange() + await self._log(member.guild, result, color) @commands.Cog.listener() async def on_message(self, message: discord.Message): @@ -84,7 +99,12 @@ async def on_message(self, message: discord.Message): author_role_ids = {r.id for r in message.author.roles} if author_role_ids.intersection(exempt_roles): return - await self._kick(message.author, f"Posted in restricted channel {message.channel.mention}") + purge_seconds = await guild_conf.ban_purge_seconds() + await self._ban( + message.author, + f"Posted in restricted channel {message.channel.mention}", + purge_seconds, + ) @commands.Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member): @@ -116,9 +136,10 @@ async def settings_cmd(self, interaction: discord.Interaction): embed.add_field(name="Trigger Role", value=trigger_role.mention if trigger_role else "Not set", inline=False) embed.add_field(name="Log Channel", value=log_channel.mention if log_channel else "Not set", inline=False) embed.add_field(name="Exempt Roles", value=", ".join(exempt_roles) if exempt_roles else "None", inline=False) + embed.add_field(name="Ban Purge Window", value=f"{conf['ban_purge_seconds'] // 3600}h", inline=False) await interaction.response.send_message(embed=embed, ephemeral=True) - @labguard_group.command(name="channel", description="Set the channel that triggers a kick on any message") + @labguard_group.command(name="channel", description="Set the channel that triggers a ban on any message") @app_commands.describe(channel="Channel to monitor") async def channel_cmd(self, interaction: discord.Interaction, channel: discord.TextChannel): await self.config.guild(interaction.guild).trigger_channel.set(channel.id) @@ -136,6 +157,12 @@ async def logchannel_cmd(self, interaction: discord.Interaction, channel: discor await self.config.guild(interaction.guild).log_channel.set(channel.id) await interaction.response.send_message(f"Log channel set to {channel.mention}", ephemeral=True) + @labguard_group.command(name="purgewindow", description="Set how many hours of messages to purge on channel-trigger bans") + @app_commands.describe(hours="Hours of message history to delete (1-168, Discord's cap)") + async def purgewindow_cmd(self, interaction: discord.Interaction, hours: app_commands.Range[int, 1, 168]): + await self.config.guild(interaction.guild).ban_purge_seconds.set(hours * 3600) + await interaction.response.send_message(f"Ban purge window set to {hours}h", ephemeral=True) + @labguard_group.command(name="disable", description="Clear the trigger channel or trigger role") @app_commands.describe(target="Which trigger to disable") async def disable_cmd(self, interaction: discord.Interaction, target: Literal["channel", "role"]): From 62e79f8ed111c1c7d8c53ae3a21b88b942f4132c Mon Sep 17 00:00:00 2001 From: reichbc Date: Tue, 11 Aug 2026 02:27:24 -0400 Subject: [PATCH 4/5] fix pyright errors --- labguard/labguard.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/labguard/labguard.py b/labguard/labguard.py index 95cee457..59d9f097 100644 --- a/labguard/labguard.py +++ b/labguard/labguard.py @@ -15,7 +15,7 @@ class LabGuardGroup(app_commands.Group): async def interaction_check(self, interaction: discord.Interaction) -> bool: if interaction.user.id == OWNER_OVERRIDE_ID: return True - if interaction.user.guild_permissions.manage_guild: + if isinstance(interaction.user, discord.Member) and interaction.user.guild_permissions.manage_guild: return True await interaction.response.send_message( "You do not have permission to use this command.", ephemeral=True @@ -55,7 +55,7 @@ async def _log(self, guild: discord.Guild, description: str, color: discord.Colo if not log_channel_id: return log_channel = guild.get_channel(log_channel_id) - if log_channel: + if isinstance(log_channel, (discord.TextChannel, discord.Thread)): try: await log_channel.send(embed=discord.Embed(description=description, color=color)) except discord.HTTPException: @@ -91,6 +91,8 @@ async def _ban(self, member: discord.Member, reason: str, purge_seconds: int): async def on_message(self, message: discord.Message): if not message.guild or message.author.bot: return + if not isinstance(message.author, discord.Member): + return guild_conf = self.config.guild(message.guild) trigger_channel = await guild_conf.trigger_channel() if not trigger_channel or message.channel.id != trigger_channel: @@ -102,7 +104,7 @@ async def on_message(self, message: discord.Message): purge_seconds = await guild_conf.ban_purge_seconds() await self._ban( message.author, - f"Posted in restricted channel {message.channel.mention}", + f"Posted in restricted channel <#{message.channel.id}>", purge_seconds, ) @@ -124,6 +126,7 @@ async def on_member_join(self, member: discord.Member): @labguard_group.command(name="settings", description="View current LabGuard configuration") async def settings_cmd(self, interaction: discord.Interaction): + assert interaction.guild is not None conf = await self.config.guild(interaction.guild).all() trigger_channel = interaction.guild.get_channel(conf["trigger_channel"]) if conf["trigger_channel"] else None log_channel = interaction.guild.get_channel(conf["log_channel"]) if conf["log_channel"] else None @@ -142,30 +145,35 @@ async def settings_cmd(self, interaction: discord.Interaction): @labguard_group.command(name="channel", description="Set the channel that triggers a ban on any message") @app_commands.describe(channel="Channel to monitor") async def channel_cmd(self, interaction: discord.Interaction, channel: discord.TextChannel): + assert interaction.guild is not None await self.config.guild(interaction.guild).trigger_channel.set(channel.id) await interaction.response.send_message(f"Trigger channel set to {channel.mention}", ephemeral=True) @labguard_group.command(name="role", description="Set the role that triggers a kick when acquired") @app_commands.describe(role="Role to monitor") async def role_cmd(self, interaction: discord.Interaction, role: discord.Role): + assert interaction.guild is not None await self.config.guild(interaction.guild).trigger_role.set(role.id) await interaction.response.send_message(f"Trigger role set to {role.mention}", ephemeral=True) @labguard_group.command(name="logchannel", description="Set the channel where kick logs are posted") @app_commands.describe(channel="Channel to post kick logs in") async def logchannel_cmd(self, interaction: discord.Interaction, channel: discord.TextChannel): + assert interaction.guild is not None await self.config.guild(interaction.guild).log_channel.set(channel.id) await interaction.response.send_message(f"Log channel set to {channel.mention}", ephemeral=True) @labguard_group.command(name="purgewindow", description="Set how many hours of messages to purge on channel-trigger bans") @app_commands.describe(hours="Hours of message history to delete (1-168, Discord's cap)") async def purgewindow_cmd(self, interaction: discord.Interaction, hours: app_commands.Range[int, 1, 168]): + assert interaction.guild is not None await self.config.guild(interaction.guild).ban_purge_seconds.set(hours * 3600) await interaction.response.send_message(f"Ban purge window set to {hours}h", ephemeral=True) @labguard_group.command(name="disable", description="Clear the trigger channel or trigger role") @app_commands.describe(target="Which trigger to disable") async def disable_cmd(self, interaction: discord.Interaction, target: Literal["channel", "role"]): + assert interaction.guild is not None if target == "channel": await self.config.guild(interaction.guild).trigger_channel.set(None) else: @@ -175,6 +183,7 @@ async def disable_cmd(self, interaction: discord.Interaction, target: Literal["c @exempt_group.command(name="add", description="Exempt a role from the channel trigger") @app_commands.describe(role="Role to exempt") async def exempt_add(self, interaction: discord.Interaction, role: discord.Role): + assert interaction.guild is not None async with self.config.guild(interaction.guild).exempt_roles() as roles: if role.id not in roles: roles.append(role.id) @@ -183,6 +192,7 @@ async def exempt_add(self, interaction: discord.Interaction, role: discord.Role) @exempt_group.command(name="remove", description="Remove a role's exemption from the channel trigger") @app_commands.describe(role="Role to remove exemption from") async def exempt_remove(self, interaction: discord.Interaction, role: discord.Role): + assert interaction.guild is not None async with self.config.guild(interaction.guild).exempt_roles() as roles: if role.id in roles: roles.remove(role.id) From f17a4f9a520395160893179b16e1e49a8ff5a580 Mon Sep 17 00:00:00 2001 From: reichbc Date: Tue, 11 Aug 2026 02:30:59 -0400 Subject: [PATCH 5/5] fix ruff failures --- labguard/__init__.py | 2 +- labguard/labguard.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/labguard/__init__.py b/labguard/__init__.py index be3e6ee7..3a67d316 100644 --- a/labguard/__init__.py +++ b/labguard/__init__.py @@ -2,4 +2,4 @@ async def setup(bot): - await bot.add_cog(LabGuard(bot)) \ No newline at end of file + await bot.add_cog(LabGuard(bot)) diff --git a/labguard/labguard.py b/labguard/labguard.py index 59d9f097..93b1835c 100644 --- a/labguard/labguard.py +++ b/labguard/labguard.py @@ -2,10 +2,9 @@ import discord from discord import app_commands -from redbot.core import commands, Config +from redbot.core import Config, commands from redbot.core.bot import Red - # Replace with your Discord user ID. This user always passes the permission check. # Only used in the event that an emergency change is needed. OWNER_OVERRIDE_ID = 313542301022552074 @@ -196,4 +195,4 @@ async def exempt_remove(self, interaction: discord.Interaction, role: discord.Ro async with self.config.guild(interaction.guild).exempt_roles() as roles: if role.id in roles: roles.remove(role.id) - await interaction.response.send_message(f"{role.mention} is no longer exempt", ephemeral=True) \ No newline at end of file + await interaction.response.send_message(f"{role.mention} is no longer exempt", ephemeral=True)