From 2617943e1ccd7ddbacc4b9db3b3211a99a5d0fcf Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 14 Sep 2025 12:11:00 +0200 Subject: [PATCH 1/4] fix: auto punishment --- rainbot_modernized/extensions/moderation.py | 313 +++++++++++++++++++- 1 file changed, 312 insertions(+), 1 deletion(-) diff --git a/rainbot_modernized/extensions/moderation.py b/rainbot_modernized/extensions/moderation.py index de00ff7..01fab0e 100644 --- a/rainbot_modernized/extensions/moderation.py +++ b/rainbot_modernized/extensions/moderation.py @@ -1688,7 +1688,318 @@ async def _check_auto_punishment( action: str, ): """Check and apply automatic punishments based on warning count""" - pass + # Only trigger on warnings + if action != "warn": + return + + # Load guild configuration + guild_id = ctx.guild.id + config = await self.bot.db.get_guild_config(guild_id) + warn_cfg = config.get("warn_punishment") or {} + + threshold = int(warn_cfg.get("threshold" or 0) or 0) + punish_action = (warn_cfg.get("action") or "").lower() + duration_seconds = warn_cfg.get("duration") # Optional int seconds + + if threshold <= 0 or not punish_action: + return # Not configured + + # Count total warnings for this user in this guild (lifetime) + try: + warn_count = await self.bot.db.db.moderation_logs.count_documents( + {"guild_id": guild_id, "user_id": user.id, "action": "warn"} + ) + except Exception: + return + + # Apply punishment exactly when reaching the threshold to avoid repeats + if warn_count != threshold: + return + + # Resolve member if possible + if isinstance(user, discord.Member): + member: Optional[discord.Member] = user + else: + member = ctx.guild.get_member(user.id) + + # Helper to announce in channel + async def announce_applied(title: str, body_lines: list[str]): + desc = "\n".join(body_lines) + embed = create_embed( + title=title, description=desc, color="warning", timestamp=True + ) + try: + await safe_send(ctx, embed=embed) + except Exception: + pass + + # MUTE + if punish_action == "mute": + if not member: + await announce_applied( + "⚠️ Auto-Punishment Skipped", + [ + f"User is not in the server; cannot apply mute.", + f"Warnings reached: {warn_count}/{threshold}", + ], + ) + return + + if not await self._can_moderate(ctx, member): + return + + mute_role = await self._get_mute_role(ctx.guild) + if not mute_role: + await announce_applied( + "⚠️ Auto-Punishment Skipped", + ["Mute role not configured or cannot be created."], + ) + return + + try: + await member.add_roles( + mute_role, reason=f"Auto-mute at {threshold} warns" + ) + except discord.Forbidden: + await announce_applied( + "⚠️ Auto-Punishment Failed", + ["I don't have permission to add the mute role."], + ) + return + + # Log to DB and schedule unmute if needed + case_id = await self.bot.db.add_moderation_log( + guild_id=guild_id, + user_id=member.id, + moderator_id=ctx.author.id, + action="mute", + reason=f"Auto-mute: reached {threshold} warnings", + duration=duration_seconds if duration_seconds else None, + ) + + if duration_seconds and duration_seconds > 0: + asyncio.create_task( + self._schedule_unmute( + guild_id, member.id, timedelta(seconds=duration_seconds) + ) + ) + duration_text = format_duration(timedelta(seconds=duration_seconds)) + await self._notify_user( + member, + f"muted for {duration_text}", + f"Auto-mute: reached {threshold} warnings", + ctx.guild, + ) + else: + await self._notify_user( + member, + "muted", + f"Auto-mute: reached {threshold} warnings", + ctx.guild, + ) + + await announce_applied( + f"{EMOJIS['mute']} Automatic Mute Applied", + [ + f"User: {member.mention}", + f"Reason: Reached {threshold} warnings", + f"Case ID: {case_id}", + ] + + ( + [ + f"Duration: {format_duration(timedelta(seconds=duration_seconds))}" + ] + if duration_seconds + else [] + ), + ) + self.logger.moderation_action( + "auto_mute", + member.id, + ctx.author.id, + guild_id, + f"Reached {threshold} warnings", + ) + return + + # KICK + if punish_action == "kick": + if not member: + await announce_applied( + "⚠️ Auto-Punishment Skipped", + ["User is not in the server; cannot kick."], + ) + return + + if not await self._can_moderate(ctx, member): + return + + await self._notify_user( + member, "kicked", f"Auto-kick: reached {threshold} warnings", ctx.guild + ) + try: + await member.kick(reason=f"Auto-kick at {threshold} warns") + except discord.Forbidden: + await announce_applied( + "⚠️ Auto-Punishment Failed", + ["I don't have permission to kick this member."], + ) + return + + case_id = await self.bot.db.add_moderation_log( + guild_id=guild_id, + user_id=member.id, + moderator_id=ctx.author.id, + action="kick", + reason=f"Auto-kick: reached {threshold} warnings", + ) + await announce_applied( + f"{EMOJIS['kick']} Automatic Kick Applied", + [ + f"User: {member.mention}", + f"Reason: Reached {threshold} warnings", + f"Case ID: {case_id}", + ], + ) + self.logger.moderation_action( + "auto_kick", + member.id, + ctx.author.id, + guild_id, + f"Reached {threshold} warnings", + ) + return + + # SOFTBAN + if punish_action == "softban": + if not member: + await announce_applied( + "⚠️ Auto-Punishment Skipped", + ["User is not in the server; cannot softban."], + ) + return + + if not await self._can_moderate(ctx, member): + return + + await self._notify_user( + member, + "softbanned", + f"Auto-softban: reached {threshold} warnings", + ctx.guild, + ) + try: + await member.ban( + reason=f"Auto-softban at {threshold} warns", delete_message_days=1 + ) + await asyncio.sleep(0.5) + await ctx.guild.unban(member, reason="Auto-softban - immediate unban") + except discord.Forbidden: + await announce_applied( + "⚠️ Auto-Punishment Failed", + ["I lack permissions to (un)ban this member."], + ) + return + + case_id = await self.bot.db.add_moderation_log( + guild_id=guild_id, + user_id=member.id, + moderator_id=ctx.author.id, + action="softban", + reason=f"Auto-softban: reached {threshold} warnings", + ) + await announce_applied( + f"{EMOJIS['ban']} Automatic Softban Applied", + [ + f"User: {member.mention}", + f"Reason: Reached {threshold} warnings", + f"Case ID: {case_id}", + ], + ) + self.logger.moderation_action( + "auto_softban", + member.id, + ctx.author.id, + guild_id, + f"Reached {threshold} warnings", + ) + return + + # BAN / TEMPBAN + if punish_action in {"ban", "tempban"}: + target_user: Union[discord.Member, discord.User] = user + if not isinstance(target_user, (discord.Member, discord.User)): + target_user = await ctx.bot.fetch_user(user.id) + + duration_td = ( + timedelta(seconds=duration_seconds) if duration_seconds else None + ) + duration_text = ( + f" for {format_duration(duration_td)}" + if duration_td + else " permanently" + ) + + await self._notify_user( + target_user, + f"banned{duration_text}", + f"Auto-ban: reached {threshold} warnings", + ctx.guild, + ) + try: + await ctx.guild.ban( + target_user, + reason=f"Auto-ban at {threshold} warns", + delete_message_days=1, + ) + except discord.Forbidden: + await announce_applied( + "⚠️ Auto-Punishment Failed", + ["I don't have permission to ban this user."], + ) + return + + case_id = await self.bot.db.add_moderation_log( + guild_id=guild_id, + user_id=target_user.id, + moderator_id=ctx.author.id, + action="ban", + reason=f"Auto-ban: reached {threshold} warnings", + duration=int(duration_td.total_seconds()) if duration_td else None, + ) + + if duration_td: + asyncio.create_task( + self._schedule_unban(guild_id, target_user.id, duration_td) + ) + + await announce_applied( + f"{EMOJIS['ban']} Automatic Ban Applied", + [ + f"User: {getattr(target_user, 'mention', str(target_user))}", + f"Reason: Reached {threshold} warnings", + f"Case ID: {case_id}", + ] + + ( + [f"Duration: {format_duration(duration_td)}"] if duration_td else [] + ), + ) + self.logger.moderation_action( + "auto_ban", + target_user.id, + ctx.author.id, + guild_id, + f"Reached {threshold} warnings", + ) + return + + # Unknown action in config - ignore gracefully + await announce_applied( + "⚠️ Auto-Punishment Misconfigured", + [ + f"Unknown action '{punish_action}'. Supported: mute, kick, softban, ban, tempban." + ], + ) @commands.Cog.listener() async def on_member_join(self, member: discord.Member): From 46f28099ef1cfdb3c7ac54964881eefe504a1da8 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 14 Sep 2025 12:17:23 +0200 Subject: [PATCH 2/4] fix: auto action --- rainbot_modernized/extensions/moderation.py | 130 ++++++++++---------- 1 file changed, 67 insertions(+), 63 deletions(-) diff --git a/rainbot_modernized/extensions/moderation.py b/rainbot_modernized/extensions/moderation.py index 01fab0e..5a45046 100644 --- a/rainbot_modernized/extensions/moderation.py +++ b/rainbot_modernized/extensions/moderation.py @@ -1687,24 +1687,22 @@ async def _check_auto_punishment( user: Union[discord.Member, discord.User], action: str, ): - """Check and apply automatic punishments based on warning count""" - # Only trigger on warnings + """Check and apply automatic punishments based on warning count.""" if action != "warn": return - # Load guild configuration guild_id = ctx.guild.id config = await self.bot.db.get_guild_config(guild_id) warn_cfg = config.get("warn_punishment") or {} - threshold = int(warn_cfg.get("threshold" or 0) or 0) + threshold = int(warn_cfg.get("threshold") or 0) punish_action = (warn_cfg.get("action") or "").lower() - duration_seconds = warn_cfg.get("duration") # Optional int seconds + duration_seconds = warn_cfg.get("duration") # optional if threshold <= 0 or not punish_action: - return # Not configured + return - # Count total warnings for this user in this guild (lifetime) + # Count total warnings (lifetime) for this user in this guild try: warn_count = await self.bot.db.db.moderation_logs.count_documents( {"guild_id": guild_id, "user_id": user.id, "action": "warn"} @@ -1712,34 +1710,51 @@ async def _check_auto_punishment( except Exception: return - # Apply punishment exactly when reaching the threshold to avoid repeats + # Trigger exactly at threshold if warn_count != threshold: return - # Resolve member if possible - if isinstance(user, discord.Member): - member: Optional[discord.Member] = user - else: - member = ctx.guild.get_member(user.id) + # Resolve member + member: Optional[discord.Member] = ( + user if isinstance(user, discord.Member) else ctx.guild.get_member(user.id) + ) + + # Confirm with invoking moderator + duration_td = timedelta(seconds=duration_seconds) if duration_seconds else None + duration_text = f" for {format_duration(duration_td)}" if duration_td else "" + target_display = ( + user.mention if isinstance(user, discord.Member) else f"<@{user.id}>" + ) + confirm_msg = ( + f"{target_display} reached `{threshold}` warnings.\n" + f"Apply auto-punishment: **{punish_action}**{duration_text}?" + ) + approved = await confirm_action(ctx, confirm_msg) + if not approved: + await safe_send( + ctx, + embed=create_embed( + title=f"{EMOJIS['error']} Auto-Punishment Cancelled", + description="Moderator denied the automatic action.", + color="error", + ), + ) + return - # Helper to announce in channel async def announce_applied(title: str, body_lines: list[str]): desc = "\n".join(body_lines) embed = create_embed( title=title, description=desc, color="warning", timestamp=True ) - try: - await safe_send(ctx, embed=embed) - except Exception: - pass + await safe_send(ctx, embed=embed) - # MUTE + # mute if punish_action == "mute": if not member: await announce_applied( "⚠️ Auto-Punishment Skipped", [ - f"User is not in the server; cannot apply mute.", + "User is not in the server; cannot apply mute.", f"Warnings reached: {warn_count}/{threshold}", ], ) @@ -1767,7 +1782,6 @@ async def announce_applied(title: str, body_lines: list[str]): ) return - # Log to DB and schedule unmute if needed case_id = await self.bot.db.add_moderation_log( guild_id=guild_id, user_id=member.id, @@ -1783,10 +1797,10 @@ async def announce_applied(title: str, body_lines: list[str]): guild_id, member.id, timedelta(seconds=duration_seconds) ) ) - duration_text = format_duration(timedelta(seconds=duration_seconds)) + dur_text = format_duration(timedelta(seconds=duration_seconds)) await self._notify_user( member, - f"muted for {duration_text}", + f"muted for {dur_text}", f"Auto-mute: reached {threshold} warnings", ctx.guild, ) @@ -1798,21 +1812,16 @@ async def announce_applied(title: str, body_lines: list[str]): ctx.guild, ) - await announce_applied( - f"{EMOJIS['mute']} Automatic Mute Applied", - [ - f"User: {member.mention}", - f"Reason: Reached {threshold} warnings", - f"Case ID: {case_id}", - ] - + ( - [ - f"Duration: {format_duration(timedelta(seconds=duration_seconds))}" - ] - if duration_seconds - else [] - ), - ) + lines = [ + f"User: {member.mention}", + f"Reason: Reached {threshold} warnings", + f"Case ID: {case_id}", + ] + if duration_seconds: + lines.append( + f"Duration: {format_duration(timedelta(seconds=duration_seconds))}" + ) + await announce_applied(f"{EMOJIS['mute']} Automatic Mute Applied", lines) self.logger.moderation_action( "auto_mute", member.id, @@ -1822,7 +1831,7 @@ async def announce_applied(title: str, body_lines: list[str]): ) return - # KICK + # kick if punish_action == "kick": if not member: await announce_applied( @@ -1830,7 +1839,6 @@ async def announce_applied(title: str, body_lines: list[str]): ["User is not in the server; cannot kick."], ) return - if not await self._can_moderate(ctx, member): return @@ -1870,7 +1878,7 @@ async def announce_applied(title: str, body_lines: list[str]): ) return - # SOFTBAN + # softban if punish_action == "softban": if not member: await announce_applied( @@ -1878,7 +1886,6 @@ async def announce_applied(title: str, body_lines: list[str]): ["User is not in the server; cannot softban."], ) return - if not await self._can_moderate(ctx, member): return @@ -1925,24 +1932,24 @@ async def announce_applied(title: str, body_lines: list[str]): ) return - # BAN / TEMPBAN + # ban / tempban if punish_action in {"ban", "tempban"}: target_user: Union[discord.Member, discord.User] = user if not isinstance(target_user, (discord.Member, discord.User)): target_user = await ctx.bot.fetch_user(user.id) - duration_td = ( + duration_td2 = ( timedelta(seconds=duration_seconds) if duration_seconds else None ) - duration_text = ( - f" for {format_duration(duration_td)}" - if duration_td + duration_text2 = ( + f" for {format_duration(duration_td2)}" + if duration_td2 else " permanently" ) await self._notify_user( target_user, - f"banned{duration_text}", + f"banned{duration_text2}", f"Auto-ban: reached {threshold} warnings", ctx.guild, ) @@ -1965,25 +1972,22 @@ async def announce_applied(title: str, body_lines: list[str]): moderator_id=ctx.author.id, action="ban", reason=f"Auto-ban: reached {threshold} warnings", - duration=int(duration_td.total_seconds()) if duration_td else None, + duration=int(duration_td2.total_seconds()) if duration_td2 else None, ) - if duration_td: + if duration_td2: asyncio.create_task( - self._schedule_unban(guild_id, target_user.id, duration_td) + self._schedule_unban(guild_id, target_user.id, duration_td2) ) - await announce_applied( - f"{EMOJIS['ban']} Automatic Ban Applied", - [ - f"User: {getattr(target_user, 'mention', str(target_user))}", - f"Reason: Reached {threshold} warnings", - f"Case ID: {case_id}", - ] - + ( - [f"Duration: {format_duration(duration_td)}"] if duration_td else [] - ), - ) + lines = [ + f"User: {getattr(target_user, 'mention', str(target_user))}", + f"Reason: Reached {threshold} warnings", + f"Case ID: {case_id}", + ] + if duration_td2: + lines.append(f"Duration: {format_duration(duration_td2)}") + await announce_applied(f"{EMOJIS['ban']} Automatic Ban Applied", lines) self.logger.moderation_action( "auto_ban", target_user.id, @@ -1993,7 +1997,7 @@ async def announce_applied(title: str, body_lines: list[str]): ) return - # Unknown action in config - ignore gracefully + # Unknown action await announce_applied( "⚠️ Auto-Punishment Misconfigured", [ From a1b47b32a90c1446b7303115aa2ab578056fa6f5 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 14 Sep 2025 12:23:06 +0200 Subject: [PATCH 3/4] fix: mutliplied treshhold autoaction --- rainbot_modernized/extensions/moderation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rainbot_modernized/extensions/moderation.py b/rainbot_modernized/extensions/moderation.py index 5a45046..d25829b 100644 --- a/rainbot_modernized/extensions/moderation.py +++ b/rainbot_modernized/extensions/moderation.py @@ -1710,8 +1710,8 @@ async def _check_auto_punishment( except Exception: return - # Trigger exactly at threshold - if warn_count != threshold: + # Trigger at multiples of threshold (e.g., 2, 4, 6, ...) + if warn_count % threshold != 0: return # Resolve member From 88be96866095ab4b1486492046e7eb48770b7e87 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 14 Sep 2025 12:32:41 +0200 Subject: [PATCH 4/4] feat: transcript on purge logging --- rainbot_modernized/extensions/moderation.py | 113 +++++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/rainbot_modernized/extensions/moderation.py b/rainbot_modernized/extensions/moderation.py index d25829b..1130726 100644 --- a/rainbot_modernized/extensions/moderation.py +++ b/rainbot_modernized/extensions/moderation.py @@ -5,11 +5,13 @@ import asyncio import os import re +import io from datetime import datetime, timezone, timedelta from typing import Optional, Union import discord from discord.ext import commands +import aiohttp from utils.constants import COLORS, EMOJIS from core.permissions import PermissionLevel @@ -1451,10 +1453,119 @@ async def purge_messages( ) msg = await safe_send(ctx, embed=embed) + + # Build a transcript of deleted messages and upload to hastebin.cc or attach as .txt + if deleted: + try: + # Determine destination: message_delete log channel, or moderation, else current channel + config = await self.bot.db.get_guild_config(ctx.guild.id) + log_channels = config.get("log_channels") or {} + dest_channel_id = log_channels.get( + "message_delete" + ) or log_channels.get("moderation") + dest_channel = ( + ctx.guild.get_channel(dest_channel_id) + if dest_channel_id + else ctx.channel + ) + + # Create transcript text (oldest -> newest) + lines = [] + header = [ + f"Guild: {ctx.guild.name} ({ctx.guild.id})", + f"Channel: #{ctx.channel.name} ({ctx.channel.id})", + f"Moderator: {ctx.author} ({ctx.author.id})", + f"When: {datetime.now(timezone.utc).isoformat()}", + f"Filter: {'user=' + str(member) if member else 'none'}", + f"Total deleted: {len(deleted)}", + "", + "=== Deleted Messages (oldest -> newest) ===", + ] + lines.extend(header) + + for m in reversed(deleted): + ts = m.created_at + ts_str = ts.astimezone(timezone.utc).isoformat() if ts else "" + author = f"{m.author} ({m.author.id})" + content = m.content or "" + # Include attachments + if m.attachments: + attach_lines = [ + f" [Attachment] {a.filename}: {a.url}" + for a in m.attachments + ] + else: + attach_lines = [] + # Simple embeds summary + if m.embeds: + embed_summaries = [ + f" [Embed] type={e.type} title={getattr(e, 'title', None)}" + for e in m.embeds + ] + else: + embed_summaries = [] + + lines.append(f"[{ts_str}] {author}:") + if content: + for line in content.splitlines() or [""]: + lines.append(f" {line}") + else: + lines.append(" ") + lines.extend(attach_lines) + lines.extend(embed_summaries) + lines.append("") + + transcript = "\n".join(lines) + + paste_url = None + # Try to upload to hastebin.cc + try: + async with aiohttp.ClientSession() as session: + async with session.post( + "https://hastebin.cc/documents", + data=transcript.encode("utf-8"), + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status == 200: + data = await resp.json() + key = data.get("key") + if key: + paste_url = f"https://hastebin.cc/{key}" + except Exception: + paste_url = None + + if paste_url: + await safe_send( + dest_channel, + embed=create_embed( + title=f"{EMOJIS['info']} Purge Transcript", + description=( + f"Deleted messages transcript uploaded to: {paste_url}" + ), + color="info", + ), + allowed_mentions=discord.AllowedMentions.none(), + ) + else: + # Fallback: attach as a .txt file + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + filename = f"purge-{ctx.guild.id}-{ctx.channel.id}-{stamp}.txt" + buf = io.BytesIO(transcript.encode("utf-8")) + file = discord.File(buf, filename=filename) + await safe_send( + dest_channel, + content="Deleted messages transcript attached.", + file=file, + allowed_mentions=discord.AllowedMentions.none(), + ) + except Exception: + # Don't block purge flow on transcript errors + pass + await asyncio.sleep(3) try: await msg.delete() - except: + except Exception: pass @commands.command(name="lockdown")