diff --git a/moderation/mute.py b/moderation/mute.py index 47f5e3b..2f3558b 100644 --- a/moderation/mute.py +++ b/moderation/mute.py @@ -133,9 +133,6 @@ class MuteCommand(ModerationBase): return try: await member.remove_roles(mute_role, reason="Mute duration expired") - channel = guild.get_channel(channel_id) - if channel: - await channel.send(f"{member.mention} has been unmuted (duration expired).") # Log automatic unmute logger = self.bot.get_cog("Logger") diff --git a/moderation/spam_protection.py b/moderation/spam_protection.py new file mode 100644 index 0000000..2c8a485 --- /dev/null +++ b/moderation/spam_protection.py @@ -0,0 +1,653 @@ +import discord +from discord.ext import commands, tasks +from discord.ui import View, Button +import asyncio +from datetime import datetime, timedelta, timezone +from collections import defaultdict, deque +import sqlite3 +import os + +MUTE_ROLE_ID = 982702037517090836 +STAFF_CHANNEL_ID = 876780367296745493 +WHITELISTED_ROLE_ID = 952560403970416722 +WHITELISTED_CATEGORY_ID = 876780338599305246 + +class SpamProtection(commands.Cog): + """Automatic spam detection and prevention system""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.db_path = os.path.join(os.path.dirname(__file__), "moderation.db") + + # Track user message patterns + # user_id -> deque of (timestamp, channel_id, content) + self.user_messages = defaultdict(lambda: deque(maxlen=50)) + + # Track users already flagged for spam (to avoid duplicate reports) + self.flagged_users = set() + + # Async queue for processing messages + self.message_queue = asyncio.Queue() + + # Start tasks + self.cleanup_tracking.start() + self.check_pending_actions.start() + self.process_message_queue.start() + + self.initialize_db() + + def initialize_db(self): + """Create table for tracking pending spam actions""" + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute(""" + CREATE TABLE IF NOT EXISTS spam_actions ( + message_id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + guild_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + spam_data TEXT NOT NULL + ) + """) + conn.commit() + conn.close() + + def cog_unload(self): + """Cleanup when cog is unloaded""" + self.cleanup_tracking.cancel() + self.check_pending_actions.cancel() + self.process_message_queue.cancel() + + @tasks.loop(minutes=5) + async def cleanup_tracking(self): + """Periodically clean up old message tracking data""" + now = datetime.now(timezone.utc) + cutoff = now - timedelta(seconds=10) + + for user_id in list(self.user_messages.keys()): + messages = self.user_messages[user_id] + # Remove messages older than 10 seconds + while messages and messages[0][0] < cutoff: + messages.popleft() + + # Remove user from tracking if no recent messages + if not messages: + del self.user_messages[user_id] + + # Clear flagged users who haven't messaged recently + self.flagged_users = { + uid for uid in self.flagged_users + if uid in self.user_messages + } + + @tasks.loop(minutes=1) + async def check_pending_actions(self): + """Check for expired spam action prompts and apply default action""" + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + + now = datetime.now(timezone.utc).isoformat() + c.execute( + "SELECT message_id, user_id, guild_id, spam_data FROM spam_actions WHERE expires_at <= ?", + (now,) + ) + expired = c.fetchall() + + for message_id, user_id, guild_id, spam_data in expired: + # Apply default action: keep mute for 1 day + await self.apply_default_action(user_id, guild_id, spam_data) + + # Remove from pending actions + c.execute("DELETE FROM spam_actions WHERE message_id = ?", (message_id,)) + + conn.commit() + conn.close() + + async def apply_default_action(self, user_id: int, guild_id: int, spam_data: str): + """Apply default action when no staff response within 12 hours""" + guild = self.bot.get_guild(guild_id) + if not guild: + return + + member = guild.get_member(user_id) + if not member: + return + + mute_role = guild.get_role(MUTE_ROLE_ID) + if not mute_role or mute_role in member.roles: + return + + # Add mute with 1 day duration using the mute system + try: + await member.add_roles(mute_role, reason="Spam protection - automatic 1 day mute (no staff action)") + + # Log using the mute command's database system + unmute_time = (datetime.utcnow() + timedelta(days=1)).isoformat() + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute(""" + INSERT OR REPLACE INTO mutes (user_id, guild_id, channel_id, unmute_time) + VALUES (?, ?, ?, ?) + """, (user_id, guild_id, STAFF_CHANNEL_ID, unmute_time)) + conn.commit() + conn.close() + + # Log to logging system + logger = self.bot.get_cog("Logger") + if logger: + await logger.log_moderation_action( + guild_id, "mute", member, self.bot.user, + "Spam protection - automatic 1 day mute (no staff response)", "1d" + ) + + # Notify staff channel + staff_channel = guild.get_channel(STAFF_CHANNEL_ID) + if staff_channel: + await staff_channel.send( + f"āš ļø No action was taken on spam report for {member.mention}. " + f"Automatically muted for 1 day." + ) + except Exception as e: + print(f"[ERROR] Failed to apply default spam action: {e}") + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + """Monitor messages for spam patterns - lightweight initial check""" + # Ignore bots and DMs + if message.author.bot or not message.guild: + return + + # Ignore users with whitelisted role + whitelisted_role = message.guild.get_role(WHITELISTED_ROLE_ID) + if whitelisted_role and whitelisted_role in message.author.roles: + return + + # Ignore messages in whitelisted category + if isinstance(message.channel, discord.TextChannel): + if message.channel.category_id == WHITELISTED_CATEGORY_ID: + return + + # Ignore users with mute role already + mute_role = message.guild.get_role(MUTE_ROLE_ID) + if mute_role and mute_role in message.author.roles: + return + + # Ignore already flagged users + if message.author.id in self.flagged_users: + return + + # Add to async queue for processing (non-blocking) + await self.message_queue.put(message) + + @tasks.loop(seconds=0.1) + async def process_message_queue(self): + """Process messages from queue asynchronously""" + try: + # Process up to 10 messages per cycle to prevent backlog + for _ in range(10): + try: + message = self.message_queue.get_nowait() + await self._process_message(message) + except asyncio.QueueEmpty: + break + except Exception as e: + print(f"[ERROR] Error in message queue processing: {e}") + + async def _process_message(self, message: discord.Message): + """Actually process the message for spam detection""" + now = datetime.now(timezone.utc) + user_id = message.author.id + + # Add message to tracking + self.user_messages[user_id].append(( + now, + message.channel.id, + message.content[:100] # Store first 100 chars + )) + + # Check for spam patterns + spam_detected = await self.check_spam_patterns(message.author, message.guild) + + if spam_detected: + await self.handle_spam(message.author, message.guild, spam_detected) + + async def check_spam_patterns(self, member: discord.Member, guild: discord.Guild): + """Check if user's message pattern indicates spam""" + messages = self.user_messages[member.id] + + if len(messages) < 2: + return None + + now = datetime.now(timezone.utc) + cutoff = now - timedelta(seconds=5) + + # Get messages in last 5 seconds + recent_messages = [msg for msg in messages if msg[0] >= cutoff] + + if len(recent_messages) < 2: + return None + + # Pattern 1: 10+ messages in same channel within 5 seconds + channel_counts = defaultdict(int) + for _, channel_id, _ in recent_messages: + channel_counts[channel_id] += 1 + + for channel_id, count in channel_counts.items(): + if count >= 10: + channel = guild.get_channel(channel_id) + return { + "type": "same_channel", + "count": count, + "channel": channel, + "messages": [msg for msg in recent_messages if msg[1] == channel_id] + } + + # Pattern 2: Messages in 10+ different channels within 5 seconds + unique_channels = len(set(msg[1] for msg in recent_messages)) + if unique_channels >= 10: + channels = {} + for _, channel_id, content in recent_messages: + if channel_id not in channels: + channels[channel_id] = guild.get_channel(channel_id) + + return { + "type": "multiple_channels", + "count": len(recent_messages), + "channel_count": unique_channels, + "channels": channels, + "messages": recent_messages + } + + return None + + async def handle_spam(self, member: discord.Member, guild: discord.Guild, spam_data: dict): + """Handle detected spam by muting user and alerting staff""" + # Mark user as flagged + self.flagged_users.add(member.id) + + # Apply mute role + mute_role = guild.get_role(MUTE_ROLE_ID) + if not mute_role: + print(f"[ERROR] Mute role {MUTE_ROLE_ID} not found in guild {guild.id}") + return + + try: + await member.add_roles(mute_role, reason="Automatic spam detection") + except discord.Forbidden: + print(f"[ERROR] Missing permissions to mute {member.id}") + return + except Exception as e: + print(f"[ERROR] Failed to mute spammer: {e}") + return + + # Try to DM the user + try: + await member.send( + f"You have been automatically muted in **{guild.name}** for spam detection. " + f"A staff member will review your case shortly." + ) + except: + pass # Can't DM user + + # Create staff alert + staff_channel = guild.get_channel(STAFF_CHANNEL_ID) + if not staff_channel: + print(f"[ERROR] Staff channel {STAFF_CHANNEL_ID} not found") + return + + # Build embed + embed = discord.Embed( + title="🚨 Spam Detected - User Auto-Muted", + color=discord.Color.red(), + timestamp=datetime.now(timezone.utc) + ) + + embed.add_field( + name="User", + value=f"{member.mention} ({member})\nID: {member.id}", + inline=False + ) + + if spam_data["type"] == "same_channel": + embed.add_field( + name="Spam Pattern", + value=f"**{spam_data['count']} messages** in {spam_data['channel'].mention} within 5 seconds", + inline=False + ) + + # Show sample messages + sample_messages = [] + for timestamp, _, content in spam_data["messages"][:5]: + time_str = timestamp.strftime("%H:%M:%S UTC") + content_preview = content[:50] + "..." if len(content) > 50 else content + sample_messages.append(f"`[{time_str}]` {content_preview}") + + if sample_messages: + embed.add_field( + name=f"Sample Messages (showing {len(sample_messages)} of {spam_data['count']})", + value="\n".join(sample_messages), + inline=False + ) + + elif spam_data["type"] == "multiple_channels": + channel_list = ", ".join([ + ch.mention for ch in list(spam_data["channels"].values())[:10] + ]) + if spam_data["channel_count"] > 10: + channel_list += f" and {spam_data['channel_count'] - 10} more..." + + embed.add_field( + name="Spam Pattern", + value=f"**{spam_data['count']} messages** across **{spam_data['channel_count']} channels** within 5 seconds", + inline=False + ) + + embed.add_field( + name="Channels", + value=channel_list, + inline=False + ) + + # Show sample messages with channels + sample_messages = [] + for timestamp, channel_id, content in spam_data["messages"][:5]: + time_str = timestamp.strftime("%H:%M:%S UTC") + channel = spam_data["channels"].get(channel_id) + channel_name = channel.mention if channel else f"<#{channel_id}>" + content_preview = content[:30] + "..." if len(content) > 30 else content + sample_messages.append(f"`[{time_str}]` {channel_name}: {content_preview}") + + if sample_messages: + embed.add_field( + name=f"Sample Messages (showing {len(sample_messages)} of {spam_data['count']})", + value="\n".join(sample_messages), + inline=False + ) + + embed.add_field( + name="Action Taken", + value="āœ… User has been automatically muted\nā° If no action is taken in 12 hours, mute will be extended to 1 day", + inline=False + ) + + embed.set_thumbnail(url=member.display_avatar.url) + embed.set_footer(text="Use buttons below to take action") + + # Create action buttons + view = SpamActionView(self.bot, member, guild, spam_data, self.db_path) + + try: + msg = await staff_channel.send(embed=embed, view=view) + + # Store pending action in database + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + expires_at = (datetime.now(timezone.utc) + timedelta(hours=12)).isoformat() + c.execute(""" + INSERT INTO spam_actions (message_id, user_id, guild_id, created_at, expires_at, spam_data) + VALUES (?, ?, ?, ?, ?, ?) + """, ( + msg.id, + member.id, + guild.id, + datetime.now(timezone.utc).isoformat(), + expires_at, + str(spam_data["type"]) # Store simple identifier + )) + conn.commit() + conn.close() + + # Store message ID in view for cleanup + view.alert_message_id = msg.id + + except Exception as e: + print(f"[ERROR] Failed to send spam alert: {e}") + +class SpamActionView(View): + """Interactive buttons for staff to handle spam reports""" + + def __init__(self, bot: commands.Bot, member: discord.Member, guild: discord.Guild, spam_data: dict, db_path: str): + super().__init__(timeout=43200) # 12 hour timeout + self.bot = bot + self.member = member + self.guild = guild + self.spam_data = spam_data + self.db_path = db_path + self.alert_message_id = None + + async def _remove_from_pending(self): + """Remove this action from pending database""" + if self.alert_message_id: + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("DELETE FROM spam_actions WHERE message_id = ?", (self.alert_message_id,)) + conn.commit() + conn.close() + + @discord.ui.button(label="Remove Mute", style=discord.ButtonStyle.green, emoji="āœ…") + async def remove_mute_button(self, interaction: discord.Interaction, button: Button): + """Remove the mute from the user""" + # Check permissions + if not interaction.user.guild_permissions.moderate_members: + await interaction.response.send_message( + "āŒ You don't have permission to take this action.", + ephemeral=True + ) + return + + # Confirmation + confirm_view = ConfirmView(interaction.user) + await interaction.response.send_message( + f"Are you sure you want to **remove the mute** from {self.member.mention}?", + view=confirm_view, + ephemeral=True + ) + await confirm_view.wait() + + if not confirm_view.confirmed: + return + + # Remove mute role + mute_role = self.guild.get_role(MUTE_ROLE_ID) + if mute_role and mute_role in self.member.roles: + try: + await self.member.remove_roles(mute_role, reason=f"Spam mute removed by {interaction.user}") + + # Remove from mutes database + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("DELETE FROM mutes WHERE user_id = ? AND guild_id = ?", + (self.member.id, self.guild.id)) + conn.commit() + conn.close() + + # Log action + logger = self.bot.get_cog("Logger") + if logger: + await logger.log_moderation_action( + self.guild.id, "unmute", self.member, interaction.user, + "Spam report - determined to be false positive" + ) + + await interaction.followup.send( + f"āœ… Mute removed from {self.member.mention}", + ephemeral=True + ) + + # Update original message + embed = interaction.message.embeds[0] + embed.color = discord.Color.green() + embed.add_field( + name="āœ… Resolution", + value=f"Mute removed by {interaction.user.mention}", + inline=False + ) + await interaction.message.edit(embed=embed, view=None) + + # Remove from pending actions + await self._remove_from_pending() + + except Exception as e: + await interaction.followup.send(f"āŒ Error: {e}", ephemeral=True) + else: + await interaction.followup.send("User is not currently muted.", ephemeral=True) + + @discord.ui.button(label="Keep Mute", style=discord.ButtonStyle.gray, emoji="ā±ļø") + async def keep_mute_button(self, interaction: discord.Interaction, button: Button): + """Keep the mute and log it""" + # Check permissions + if not interaction.user.guild_permissions.moderate_members: + await interaction.response.send_message( + "āŒ You don't have permission to take this action.", + ephemeral=True + ) + return + + # Confirmation + confirm_view = ConfirmView(interaction.user) + await interaction.response.send_message( + f"Are you sure you want to **keep the mute** on {self.member.mention}? This will maintain the current mute indefinitely.", + view=confirm_view, + ephemeral=True + ) + await confirm_view.wait() + + if not confirm_view.confirmed: + return + + # Just log the decision - mute is already applied + logger = self.bot.get_cog("Logger") + if logger: + await logger.log_moderation_action( + self.guild.id, "mute", self.member, interaction.user, + "Spam confirmed - keeping mute indefinitely", "indefinite" + ) + + await interaction.followup.send( + f"āœ… Mute kept on {self.member.mention} (indefinite)", + ephemeral=True + ) + + # Update original message + embed = interaction.message.embeds[0] + embed.color = discord.Color.orange() + embed.add_field( + name="ā±ļø Resolution", + value=f"Mute kept (indefinite) by {interaction.user.mention}", + inline=False + ) + await interaction.message.edit(embed=embed, view=None) + + # Remove from pending actions + await self._remove_from_pending() + + @discord.ui.button(label="Ban User", style=discord.ButtonStyle.red, emoji="šŸ”Ø") + async def ban_button(self, interaction: discord.Interaction, button: Button): + """Ban the user""" + # Check permissions + if not interaction.user.guild_permissions.ban_members: + await interaction.response.send_message( + "āŒ You don't have permission to ban members.", + ephemeral=True + ) + return + + # Confirmation + confirm_view = ConfirmView(interaction.user) + await interaction.response.send_message( + f"Are you sure you want to **ban** {self.member.mention} for spam?", + view=confirm_view, + ephemeral=True + ) + await confirm_view.wait() + + if not confirm_view.confirmed: + return + + reason = f"Spam (detected by automatic system, banned by {interaction.user})" + + # Try to DM user + try: + await self.member.send( + f"You have been **banned** from **{self.guild.name}** for spam.\n" + f"Reason: {reason}" + ) + except: + pass + + # Ban user + try: + await self.guild.ban(self.member, reason=reason) + + # Log infraction (using ModerationBase system) + mute_cog = self.bot.get_cog("MuteCommand") + if mute_cog and hasattr(mute_cog, 'log_infraction'): + await mute_cog.log_infraction( + self.guild.id, self.member.id, interaction.user.id, "ban", reason + ) + + # Log to logging system + logger = self.bot.get_cog("Logger") + if logger: + await logger.log_moderation_action( + self.guild.id, "ban", self.member, interaction.user, reason + ) + + await interaction.followup.send( + f"āœ… {self.member.mention} has been banned.", + ephemeral=True + ) + + # Update original message + embed = interaction.message.embeds[0] + embed.color = discord.Color.dark_red() + embed.add_field( + name="šŸ”Ø Resolution", + value=f"User banned by {interaction.user.mention}", + inline=False + ) + await interaction.message.edit(embed=embed, view=None) + + # Remove from pending actions + await self._remove_from_pending() + + except Exception as e: + await interaction.followup.send(f"āŒ Failed to ban: {e}", ephemeral=True) + +class ConfirmView(View): + """Simple confirmation dialog""" + + def __init__(self, user: discord.User): + super().__init__(timeout=30) + self.user = user + self.confirmed = False + + @discord.ui.button(label="Confirm", style=discord.ButtonStyle.green) + async def confirm_button(self, interaction: discord.Interaction, button: Button): + if interaction.user.id != self.user.id: + await interaction.response.send_message( + "āŒ Only the moderator who initiated this action can confirm.", + ephemeral=True + ) + return + + self.confirmed = True + await interaction.response.edit_message(content="āœ… Confirmed.", view=None) + self.stop() + + @discord.ui.button(label="Cancel", style=discord.ButtonStyle.gray) + async def cancel_button(self, interaction: discord.Interaction, button: Button): + if interaction.user.id != self.user.id: + await interaction.response.send_message( + "āŒ Only the moderator who initiated this action can cancel.", + ephemeral=True + ) + return + + self.confirmed = False + await interaction.response.edit_message(content="āŒ Cancelled.", view=None) + self.stop() + +async def setup(bot: commands.Bot): + await bot.add_cog(SpamProtection(bot)) \ No newline at end of file