"""Moderation cog implementing the Excommunicado XCOM isolation system for flood prevention. Commands: - /xcom-setup : one-time server setup (creates role + xcom-lounge shared room) - /xcom @user [reason] : excommunicate offending user into the shared room - /xcom-release @user : restore user to normal access - /xcom-list : show currently XCOM users """ from datetime import datetime, timezone from typing import Optional import logging import discord from discord.ext import commands from discord import app_commands from utils.config import ( get_guild_data, update_guild_data, add_isolated_user, remove_isolated_user, get_isolated_users, cast_vote, get_vote_score, get_keyword_points, clear_votes, add_keyword, remove_keyword, get_keywords, ) logger = logging.getLogger("excommunicado.moderation") # --- Community vote configuration --- VOTE_XCOM_THRESHOLD = 10 # net score required to auto-XCOM a user VOTE_ADMIN_WEIGHT = 2 # admins / moderators count double VOTE_USER_WEIGHT = 1 # regular members VOTE_COOLDOWN_HOURS = 24 # one vote per voter per target per 24h VOTE_RESET_DAYS = 7 # tallies reset weekly VOTE_MIN_UNIQUE_VOTERS = 5 # quorum: distinct voters required before XCOM fires async def get_or_create_xcom_role(guild: discord.Guild) -> discord.Role: """Get existing Excommunicado role or create a new one.""" data = get_guild_data(guild.id) role_id = data.get("xcom_role_id") if role_id: role = guild.get_role(role_id) if role: return role # Create role with dark red color (Excommunicado theme) role = await guild.create_role( name="Excommunicado", color=discord.Color.dark_red(), hoist=False, mentionable=False, reason="Excommunicado role for violation containment" ) update_guild_data(guild.id, "xcom_role_id", role.id) return role async def get_or_create_xcom_channel(guild: discord.Guild, role: discord.Role) -> discord.TextChannel: """Get or create the shared 'xcom-lounge' text channel.""" data = get_guild_data(guild.id) ch_id = data.get("xcom_channel_id") if ch_id: channel = guild.get_channel(ch_id) if channel and isinstance(channel, discord.TextChannel): return channel overwrites = { guild.default_role: discord.PermissionOverwrite( view_channel=False, send_messages=False, read_message_history=False ), role: discord.PermissionOverwrite( view_channel=True, send_messages=True, read_message_history=True, embed_links=True ), } channel = await guild.create_text_channel( name="xcom-lounge", overwrites=overwrites, topic="Moderated discussion lounge. Staff are here to help resolve issues.", reason="Creating shared moderated room for Excommunicado", slowmode_delay=5, # light slowmode to further reduce flood ) update_guild_data(guild.id, "xcom_channel_id", channel.id) return channel def _staff_roles(guild: discord.Guild): """Yield roles that should be treated as moderation staff. A role counts as staff if it grants administrator or any common moderation permission. Used to auto-grant access to the private xcom-mod channel. """ for r in guild.roles: p = r.permissions if (p.administrator or p.manage_guild or p.manage_roles or p.ban_members or p.kick_members or p.manage_messages): yield r async def get_or_create_xcom_mod_channel(guild: discord.Guild, role: Optional[discord.Role] = None) -> discord.TextChannel: """Get or create the private 'xcom-mod' staff-only review channel. This channel is hidden from everyone (including the Excommunicado role) and is where the bot relays vows, vote/keyword auto-XCOM alerts, and other moderation notices. Isolated users can never see it; the bot posts on their behalf, so they never need write access here. """ if role is None: role = await get_or_create_xcom_role(guild) data = get_guild_data(guild.id) ch_id = data.get("xcom_mod_channel_id") if ch_id: channel = guild.get_channel(ch_id) if channel and isinstance(channel, discord.TextChannel): return channel overwrites = { # Hidden from regular members... guild.default_role: discord.PermissionOverwrite(view_channel=False), # ...and explicitly hidden from isolated users. role: discord.PermissionOverwrite(view_channel=False), # The bot must be able to post relayed vows and alerts here. guild.me: discord.PermissionOverwrite( view_channel=True, send_messages=True, embed_links=True, read_message_history=True ), } # Grant staff roles read/write access. for staff_role in _staff_roles(guild): overwrites[staff_role] = discord.PermissionOverwrite( view_channel=True, send_messages=True, read_message_history=True, embed_links=True ) channel = await guild.create_text_channel( name="xcom-mod", overwrites=overwrites, topic="Private staff channel: XCOM vows, auto-XCOM alerts, and moderation review. Not visible to isolated users.", reason="Creating private staff review channel for Excommunicado", ) update_guild_data(guild.id, "xcom_mod_channel_id", channel.id) return channel async def _post_to_mod_channel(guild: discord.Guild, role: Optional[discord.Role], embed: discord.Embed) -> bool: """Relay an embed to the private xcom-mod channel, creating it if needed. Returns True if the message was delivered. """ try: channel = await get_or_create_xcom_mod_channel(guild, role) await channel.send(embed=embed) return True except discord.Forbidden: logger.warning("Missing permission to create/post in xcom-mod for guild %s", guild.id) except discord.HTTPException as err: logger.warning("Failed to post to xcom-mod for guild %s: %s", guild.id, err) return False def _is_staff_member(member) -> bool: """True if the message author is staff (admin/mod permissions).""" perms = getattr(member, "guild_permissions", None) if perms is None: return False return (perms.administrator or perms.manage_guild or perms.manage_roles or perms.ban_members or perms.kick_members or perms.manage_messages) async def purge_lounge_for_user(guild: discord.Guild, user: discord.abc.Snowflake, limit: int = 2000) -> int: """Clear the absolved user's messages plus all staff/bot messages from the lounge. Messages from *other* excommunicated (non-staff) users are left untouched. Returns the number of messages deleted (0 if the channel is missing or the bot lacks Manage Messages / Read Message History). """ data = get_guild_data(guild.id) ch_id = data.get("xcom_channel_id") channel = guild.get_channel(ch_id) if ch_id else None if not channel or not isinstance(channel, discord.TextChannel): return 0 me = guild.me def _should_delete(msg: discord.Message) -> bool: # The absolved user's own messages. if msg.author.id == user.id: return True # Bot / system messages (welcome embeds, notices) count as admin-side. if me is not None and msg.author.id == me.id: return True # Any staff/admin/witness messages. if _is_staff_member(msg.author): return True # Otherwise it belongs to another excommunicated user — keep it. return False try: # bulk=True deletes messages <14 days old in batches and older ones # individually; the check filters which messages are removed. deleted = await channel.purge( limit=limit, check=_should_delete, bulk=True, reason=f"XCOM cleanup: removed {user} + staff messages on absolution", ) logger.info( "Purged %d lounge messages (absolved=%s + staff/bot) in guild %s", len(deleted), user.id, guild.id, ) return len(deleted) except discord.Forbidden: logger.warning("Missing permission to purge xcom-lounge in guild %s " "(need Manage Messages + Read Message History)", guild.id) except discord.HTTPException as err: logger.warning("Failed to purge xcom-lounge in guild %s: %s", guild.id, err) return 0 def _keyword_list_embed(guild: discord.Guild) -> discord.Embed: """Build an embed listing the guild's auto-score keywords.""" keywords = get_keywords(guild.id) embed = discord.Embed( title="🚩 Auto-Score Keywords", description=( "Messages matching these phrases add points to the sender's community score.\n" "Reaching the XCOM threshold auto-isolates them — even when no admin is online.\n" "_Staff and the server owner are immune._" ), color=discord.Color.orange(), ) if not keywords: embed.add_field(name="No keywords yet", value="Use **Add / Edit** to create one.", inline=False) else: lines = [] for _, info in sorted(keywords.items(), key=lambda kv: -int(kv[1].get("weight", 0))): lines.append(f"• `{info.get('display', '?')}` — **+{int(info.get('weight', 1))}**") # Embed field values cap at 1024 chars; chunk if needed chunk = "" idx = 1 for line in lines: if len(chunk) + len(line) + 1 > 1024: embed.add_field(name=f"Phrases ({idx})", value=chunk, inline=False) chunk = "" idx += 1 chunk += line + "\n" if chunk: embed.add_field(name=f"Phrases ({idx})" if idx > 1 else "Phrases", value=chunk, inline=False) embed.set_footer(text="Curate carefully — this is a cybersecurity channel; some terms are legitimate in context.") return embed class KeywordAddModal(discord.ui.Modal, title="Add / Edit Auto-Score Keyword"): phrase = discord.ui.TextInput( label="Keyword or phrase", placeholder="e.g. cracked software, free nitro generator, warez", max_length=200, required=True, ) weight = discord.ui.TextInput( label="Score weight (integer, e.g. 1-10)", placeholder="Higher = closer to instant XCOM. Threshold is 10.", max_length=4, required=True, default="1", ) def __init__(self, parent_view: "KeywordManagerView"): super().__init__() self.parent_view = parent_view async def on_submit(self, interaction: discord.Interaction): try: weight_val = int(str(self.weight.value).strip()) except ValueError: await interaction.response.send_message("Weight must be a whole number.", ephemeral=True) return if weight_val < 1 or weight_val > 100: await interaction.response.send_message("Weight must be between 1 and 100.", ephemeral=True) return entry = add_keyword(interaction.guild.id, str(self.phrase.value), weight_val, interaction.user.id) if entry is None: await interaction.response.send_message("That phrase was empty after normalization.", ephemeral=True) return await interaction.response.edit_message( embed=_keyword_list_embed(interaction.guild), view=self.parent_view ) class KeywordRemoveModal(discord.ui.Modal, title="Remove Auto-Score Keyword"): phrase = discord.ui.TextInput( label="Keyword or phrase to remove", placeholder="Type the phrase exactly as listed", max_length=200, required=True, ) def __init__(self, parent_view: "KeywordManagerView"): super().__init__() self.parent_view = parent_view async def on_submit(self, interaction: discord.Interaction): removed = remove_keyword(interaction.guild.id, str(self.phrase.value)) if not removed: await interaction.response.send_message("No matching keyword was found.", ephemeral=True) return await interaction.response.edit_message( embed=_keyword_list_embed(interaction.guild), view=self.parent_view ) class KeywordManagerView(discord.ui.View): """Interactive admin panel for curating auto-score keywords.""" def __init__(self, author_id: int): super().__init__(timeout=180) self.author_id = author_id async def interaction_check(self, interaction: discord.Interaction) -> bool: # Only the invoking admin (with Manage Server) may operate the panel. if interaction.user.id != self.author_id or not interaction.user.guild_permissions.manage_guild: await interaction.response.send_message( "Only the admin who opened this panel can use it.", ephemeral=True ) return False return True @discord.ui.button(label="Add / Edit", style=discord.ButtonStyle.success, emoji="➕") async def add_edit(self, interaction: discord.Interaction, button: discord.ui.Button): await interaction.response.send_modal(KeywordAddModal(self)) @discord.ui.button(label="Remove", style=discord.ButtonStyle.danger, emoji="🗑️") async def remove(self, interaction: discord.Interaction, button: discord.ui.Button): await interaction.response.send_modal(KeywordRemoveModal(self)) @discord.ui.button(label="Refresh", style=discord.ButtonStyle.secondary, emoji="🔄") async def refresh(self, interaction: discord.Interaction, button: discord.ui.Button): await interaction.response.edit_message(embed=_keyword_list_embed(interaction.guild), view=self) class ModerationCog(commands.Cog): """XCOM commands and logic.""" def __init__(self, bot: commands.Bot): self.bot = bot async def _perform_xcom(self, guild: discord.Guild, user: discord.Member, reason: str, applied_by: str): """Shared XCOM logic used by /xcom and the community vote trigger. Returns (channel, already_isolated). Raises discord.Forbidden if the Excommunicado role cannot be assigned (caller should handle it). """ role = await get_or_create_xcom_role(guild) channel = await get_or_create_xcom_channel(guild, role) if role in user.roles: return channel, True await user.add_roles(role, reason=f"XCOM ({applied_by}): {reason}") # Apply permanent XCOM Mark (nickname prefix) - best effort try: current_nick = user.nick or user.name if not current_nick.startswith("[XCOM]"): new_nick = f"[XCOM] {current_nick}"[:32] await user.edit(nick=new_nick, reason="XCOM permanent mark applied") except discord.Forbidden: pass timestamp = datetime.now(timezone.utc).isoformat() add_isolated_user(guild.id, user.id, reason, timestamp) welcome = discord.Embed( title="🪐 XCOM Lounge", description=( f"**Reason for moderation:** {reason}\n\n" "You are now in the shared XCOM discussion area with staff.\n" "Your messages in other channels are being handled privately here.\n" "This helps keep the main server clean while we resolve the issue.\n\n" "Staff are present in this room to assist you." ), color=discord.Color.dark_red() ) welcome.set_footer(text=f"Isolated at {timestamp[:19]}") try: await channel.send(f"{user.mention}", embed=welcome) except Exception: pass try: await user.send(embed=welcome) except discord.Forbidden: pass return channel, False @app_commands.command(name="xcom-setup", description="One-time setup: create XCOM role and xcom-lounge shared room") @app_commands.checks.has_permissions(manage_guild=True) async def xcom_setup(self, interaction: discord.Interaction): """Run this once per server to initialize the isolation system.""" await interaction.response.defer(ephemeral=True) guild = interaction.guild role = await get_or_create_xcom_role(guild) channel = await get_or_create_xcom_channel(guild, role) try: mod_channel = await get_or_create_xcom_mod_channel(guild, role) except discord.Forbidden: mod_channel = None embed = discord.Embed( title="🪐 XCOM System Ready", description="The bot can now XCOM users to contain violations without banning them.", color=discord.Color.orange() ) embed.add_field(name="Role Created", value=f"{role.mention} (ID: {role.id})", inline=True) embed.add_field(name="Lounge Channel", value=f"{channel.mention}", inline=True) embed.add_field( name="Staff Review Channel", value=(mod_channel.mention if mod_channel else "⚠️ Could not create `xcom-mod` (missing Manage Channels permission)."), inline=True, ) embed.add_field( name="⚠️ Stealth Mode Active", value=( "The **Excommunicado** role is now only a marker + grants access to the shared moderated room.\n\n" "**Do NOT** deny View/Send on public channels (user must keep seeing the normal server).\n" "Normal members cannot see xcom-lounge (already configured).\n\n" "Staff members you want to 'share the room' with the isolated user can be given the Excommunicado role manually.\n\n" "🔒 **xcom-mod** is a private staff channel (hidden from members and isolated users). " "The bot relays vows and auto-XCOM alerts there — isolated users never see it." ), inline=False ) embed.set_footer(text="After setup, use /xcom @user to excommunicate offenders. They will not realize they have been moved.") await interaction.followup.send(embed=embed, ephemeral=True) @app_commands.command(name="xcom", description="Excommunicate a user into the shared moderated room (anti-violation)") @app_commands.checks.has_permissions(manage_roles=True) @app_commands.checks.cooldown(5, 15.0, key=lambda i: (i.guild_id, i.user.id)) @app_commands.describe(user="The user who violated rules", reason="Reason for XCOM") async def xcom(self, interaction: discord.Interaction, user: discord.Member, reason: Optional[str] = "Flooding the channel"): """Excommunicate the user by assigning the Excommunicado role so they are contained in the xcom-lounge.""" if user.bot: await interaction.response.send_message("Cannot XCOM bots.", ephemeral=True) return if user.id == interaction.user.id: await interaction.response.send_message("You cannot XCOM yourself.", ephemeral=True) return if user.id == interaction.guild.owner_id: await interaction.response.send_message("You cannot XCOM the server owner.", ephemeral=True) return # Role-hierarchy safety: the invoker must outrank the target, # and the bot must be able to manage the target. invoker = interaction.user if isinstance(invoker, discord.Member) and invoker.id != interaction.guild.owner_id: if user.top_role >= invoker.top_role: await interaction.response.send_message( "You cannot XCOM a member with an equal or higher role than yours.", ephemeral=True, ) return me = interaction.guild.me if me is not None and user.top_role >= me.top_role: await interaction.response.send_message( "I cannot XCOM this member — their highest role is above mine. Move my role higher.", ephemeral=True, ) return # Defer the response because this command performs multiple async operations await interaction.response.defer(ephemeral=True) guild = interaction.guild try: channel, already = await self._perform_xcom(guild, user, reason, f"by {interaction.user}") except discord.Forbidden: await interaction.followup.send("Missing permissions to assign role. Check bot role hierarchy.", ephemeral=True) return if already: await interaction.followup.send(f"{user.mention} is already under XCOM.", ephemeral=True) return # Log action log_embed = discord.Embed( title="🪐 User XCOM'd", description=f"{user.mention} ({user.id}) was placed in {channel.mention}", color=discord.Color.dark_red(), timestamp=datetime.now(timezone.utc) ) log_embed.add_field(name="Reason", value=reason, inline=False) log_embed.add_field(name="Admin", value=interaction.user.mention, inline=True) if hasattr(self.bot, "log_action"): await self.bot.log_action(guild, log_embed) await interaction.followup.send( f"✅ {user.mention} has been XCOM'd into the shared room {channel.mention}.\nThey continue to see the server normally.", ephemeral=True ) @app_commands.command(name="xcom-release", description="Release a user from XCOM back to normal server access (XCOM Mark remains until admin removes it)") @app_commands.checks.has_permissions(manage_roles=True) @app_commands.describe(user="User to release from XCOM") async def xcom_release(self, interaction: discord.Interaction, user: discord.Member): """Remove the Excommunicado role. The [XCOM] nickname mark stays until an admin runs /xcom-unmark.""" await interaction.response.defer(ephemeral=True) guild = interaction.guild data = get_guild_data(guild.id) role_id = data.get("xcom_role_id") role = guild.get_role(role_id) if role_id else None if not role or role not in user.roles: await interaction.followup.send(f"{user.mention} is not currently under XCOM.", ephemeral=True) return try: await user.remove_roles(role, reason=f"Released from XCOM by {interaction.user}") except discord.Forbidden: await interaction.followup.send("Missing permissions to remove role. Check bot role hierarchy.", ephemeral=True) return remove_isolated_user(guild.id, user.id) release_embed = discord.Embed( title="✅ You have been released from XCOM", description="You now have full access to the server again. Please follow the rules.", color=discord.Color.green() ) try: await user.send(embed=release_embed) except discord.HTTPException: pass # Clear this user's messages plus all staff/bot messages from the lounge, # leaving other excommunicated users' messages intact. purged = await purge_lounge_for_user(guild, user) purge_note = ( f" Cleared {purged} lounge message(s) (their posts + staff/bot messages)." if purged else "" ) await interaction.followup.send( f"✅ {user.mention} has been released from XCOM and restored to normal access. " f"The [XCOM] mark remains until an admin removes it.{purge_note}", ephemeral=True ) @app_commands.command(name="xcom-unmark", description="Remove the permanent [XCOM] nickname mark from a user (admin only)") @app_commands.checks.has_permissions(manage_roles=True) @app_commands.describe(user="User whose XCOM mark should be removed") async def xcom_unmark(self, interaction: discord.Interaction, user: discord.Member): """Remove the [XCOM] nickname prefix. Only admins can do this.""" try: current_nick = user.nick or user.name if current_nick.startswith("[XCOM]"): new_nick = current_nick[7:].strip() or user.name # remove "[XCOM] " await user.edit(nick=new_nick, reason=f"XCOM mark removed by {interaction.user}") await interaction.response.send_message(f"✅ Removed XCOM mark from {user.mention}.", ephemeral=True) else: await interaction.response.send_message(f"{user.mention} does not have an XCOM mark.", ephemeral=True) except discord.Forbidden: await interaction.response.send_message("Bot lacks permission to change this user's nickname.", ephemeral=True) @app_commands.command(name="xcom-vow", description="Submit a Vow of Redemption while under XCOM (visible to staff in the lounge)") @app_commands.describe(vow="Your promise or statement (max 200 characters)") async def xcom_vow(self, interaction: discord.Interaction, vow: str): """Allow an XCOM user to submit a vow in the lounge.""" guild = interaction.guild data = get_guild_data(guild.id) xcom_role_id = data.get("xcom_role_id") if not xcom_role_id or xcom_role_id not in [r.id for r in interaction.user.roles]: await interaction.response.send_message("You must be under XCOM to submit a vow.", ephemeral=True) return if len(vow) > 200: await interaction.response.send_message("Vow is too long (max 200 characters).", ephemeral=True) return role = guild.get_role(xcom_role_id) vow_embed = discord.Embed( title="📜 XCOM Vow Submitted", description=f"**{interaction.user.mention}** ({interaction.user.id}) has made the following vow:\n\n> {vow}", color=discord.Color.dark_red(), timestamp=datetime.now(timezone.utc), ) vow_embed.set_footer(text="Relayed by the bot to staff — the user cannot see this channel.") # The bot relays the vow into the private staff channel, so the isolated # user never needs (or gets) access to xcom-mod themselves. delivered = await _post_to_mod_channel(guild, role, vow_embed) if role else False if delivered: logger.info("Vow relayed to xcom-mod: guild=%s user=%s", guild.id, interaction.user.id) await interaction.response.send_message( "Your vow has been submitted to staff for review.", ephemeral=True ) else: await interaction.response.send_message( "Could not deliver your vow — staff have been unable to set up the review channel. " "Please try again later.", ephemeral=True, ) @app_commands.command(name="xcom-witness", description="Add a staff member to the xcom-lounge so they can support a specific user") @app_commands.checks.has_permissions(manage_roles=True) @app_commands.describe(staff="Staff member to add as witness", target="XCOM user they will support") async def xcom_witness(self, interaction: discord.Interaction, staff: discord.Member, target: discord.Member): """Give a staff member the Excommunicado role so they can join the shared room.""" guild = interaction.guild data = get_guild_data(guild.id) role_id = data.get("xcom_role_id") role = guild.get_role(role_id) if role_id else None if not role: await interaction.response.send_message("XCOM system not set up. Run /xcom-setup first.", ephemeral=True) return if role in staff.roles: await interaction.response.send_message(f"{staff.mention} is already a witness.", ephemeral=True) return try: await staff.add_roles(role, reason=f"Added as XCOM witness for {target} by {interaction.user}") await interaction.response.send_message(f"✅ {staff.mention} can now join the xcom-lounge as a witness.", ephemeral=True) except discord.Forbidden: await interaction.response.send_message("Missing permissions to assign the role.", ephemeral=True) @app_commands.command(name="xcom-absolve", description="Check absolution points and release a user early (admin only)") @app_commands.checks.has_permissions(manage_roles=True) @app_commands.checks.cooldown(5, 15.0, key=lambda i: (i.guild_id, i.user.id)) @app_commands.describe(user="XCOM user to evaluate for absolution") async def xcom_absolve(self, interaction: discord.Interaction, user: discord.Member): """If the user has enough absolution points, remove role and optionally the mark.""" guild = interaction.guild data = get_guild_data(guild.id) role_id = data.get("xcom_role_id") role = guild.get_role(role_id) if role_id else None if not role or role not in user.roles: await interaction.response.send_message(f"{user.mention} is not under XCOM.", ephemeral=True) return # Defer because this command performs async operations await interaction.response.defer(ephemeral=True) isolated = get_isolated_users(guild.id) user_data = isolated.get(str(user.id), {}) points = user_data.get("absolution_points", 0) if points < 10: # Threshold for absolution await interaction.followup.send( f"{user.mention} has {points} absolution points (need 10). Not ready for absolution yet.", ephemeral=True ) return # Absolve: remove role (mark stays unless admin uses /xcom-unmark) await user.remove_roles(role, reason=f"Absolved by {interaction.user} ({points} points)") # Optional: remove from isolated list but keep the mark flag remove_isolated_user(guild.id, user.id) # Clear this user's messages plus all staff/bot messages from the lounge, # leaving other excommunicated users' messages intact. purged = await purge_lounge_for_user(guild, user) purge_note = ( f" Cleared {purged} lounge message(s) (their posts + staff/bot messages)." if purged else "" ) await interaction.followup.send( f"✅ {user.mention} has been absolved ({points} points). The [XCOM] mark remains until manually removed." f"{purge_note}", ephemeral=True ) @app_commands.command(name="xcom-list", description="List all users currently under XCOM") @app_commands.checks.has_permissions(manage_roles=True) async def xcom_list(self, interaction: discord.Interaction): """Show currently isolated users with reasons.""" guild = interaction.guild isolated = get_isolated_users(guild.id) if not isolated: await interaction.response.send_message("No users are currently under XCOM.", ephemeral=True) return embed = discord.Embed( title="🪐 Currently XCOM Users", color=discord.Color.orange() ) for uid, info in list(isolated.items())[:15]: # limit embed size member = guild.get_member(int(uid)) name = member.mention if member else f"Unknown ({uid})" reason = info.get("reason", "Unknown") ts = info.get("timestamp", "")[:10] embed.add_field(name=name, value=f"Reason: {reason}\nSince: {ts}", inline=False) embed.set_footer(text=f"Total: {len(isolated)} | Use /xcom-release to restore access") await interaction.response.send_message(embed=embed, ephemeral=True) @app_commands.command(name="xcom-vote", description="Cast a community vote to excommunicate (up) or defend (down) a user") @app_commands.checks.cooldown(10, 60.0, key=lambda i: (i.guild_id, i.user.id)) @app_commands.describe(user="The user to vote on", direction="up = excommunicate, down = defend") @app_commands.choices(direction=[ app_commands.Choice(name="up (excommunicate)", value="up"), app_commands.Choice(name="down (defend)", value="down"), ]) async def xcom_vote(self, interaction: discord.Interaction, user: discord.Member, direction: app_commands.Choice[str]): """Community-driven XCOM. Net score >= threshold (with quorum) auto-isolates the target.""" guild = interaction.guild voter = interaction.user # --- Guardrails --- if user.bot: await interaction.response.send_message("You cannot vote on bots.", ephemeral=True) return if user.id == voter.id: await interaction.response.send_message("You cannot vote on yourself.", ephemeral=True) return if user.id == guild.owner_id: await interaction.response.send_message("You cannot vote against the server owner.", ephemeral=True) return # Staff / moderator immunity if user.guild_permissions.manage_roles or user.guild_permissions.administrator: await interaction.response.send_message("You cannot vote against staff or moderators.", ephemeral=True) return data = get_guild_data(guild.id) role_id = data.get("xcom_role_id") role = guild.get_role(role_id) if role_id else None if role and role in user.roles: await interaction.response.send_message(f"{user.mention} is already under XCOM.", ephemeral=True) return # The bot must be able to enforce the result me = guild.me if me is not None and user.top_role >= me.top_role: await interaction.response.send_message( "That member ranks above me, so a community XCOM could not be enforced.", ephemeral=True ) return await interaction.response.defer(ephemeral=True) is_staff = voter.guild_permissions.administrator or voter.guild_permissions.manage_roles weight = VOTE_ADMIN_WEIGHT if is_staff else VOTE_USER_WEIGHT dir_val = 1 if direction.value == "up" else -1 result = cast_vote( guild.id, user.id, voter.id, dir_val, weight, cooldown_hours=VOTE_COOLDOWN_HOURS, reset_days=VOTE_RESET_DAYS, ) if not result["ok"]: if result["reason"] == "cooldown": await interaction.followup.send( f"⏳ You've already voted on this user. You can vote again in ~{result['retry_hours']:.1f}h " "(one vote per user per 24h).", ephemeral=True, ) else: await interaction.followup.send("Your vote could not be recorded.", ephemeral=True) return score = result["score"] unique_voters = result["unique_voters"] # --- Threshold reached: trigger the community XCOM --- if score >= VOTE_XCOM_THRESHOLD and unique_voters >= VOTE_MIN_UNIQUE_VOTERS: try: channel, already = await self._perform_xcom( guild, user, "Community vote threshold reached", "community vote" ) except discord.Forbidden: await interaction.followup.send( "Your vote was recorded, but I lack permission to XCOM this user.", ephemeral=True ) return clear_votes(guild.id, user.id) log_embed = discord.Embed( title="🪐 Community XCOM Triggered", description=f"{user.mention} ({user.id}) reached the community vote threshold and was placed in {channel.mention}.", color=discord.Color.dark_red(), timestamp=datetime.now(timezone.utc), ) log_embed.add_field(name="Final score", value=str(score), inline=True) log_embed.add_field(name="Distinct voters", value=str(unique_voters), inline=True) await _post_to_mod_channel(guild, role, log_embed) if hasattr(self.bot, "log_action"): await self.bot.log_action(guild, log_embed) await interaction.followup.send( f"✅ The community has spoken — the score reached **{score}** and the user has been excommunicated.", ephemeral=True, ) return # --- Not yet --- arrow = "⬆️" if dir_val > 0 else "⬇️" remaining = VOTE_XCOM_THRESHOLD - score progress = f" — {remaining} more to trigger XCOM" if remaining > 0 else "" quorum_note = ( "" if unique_voters >= VOTE_MIN_UNIQUE_VOTERS else f"\n_Needs ≥{VOTE_MIN_UNIQUE_VOTERS} distinct voters (currently {unique_voters})._" ) await interaction.followup.send( f"{arrow} Vote recorded. Current score: **{score}/{VOTE_XCOM_THRESHOLD}**{progress}." f"{quorum_note}\n_Tallies reset weekly. One vote per user per 24h._", ephemeral=True, ) @app_commands.command(name="xcom-votes", description="View the current community vote tally for a user (staff)") @app_commands.checks.has_permissions(manage_roles=True) @app_commands.describe(user="User to inspect") async def xcom_votes(self, interaction: discord.Interaction, user: discord.Member): """Show the running community vote score and quorum status for a user.""" score, unique_voters = get_vote_score(interaction.guild.id, user.id, reset_days=VOTE_RESET_DAYS) kw_points = get_keyword_points(interaction.guild.id, user.id, reset_days=VOTE_RESET_DAYS) embed = discord.Embed(title="🗳️ Community Vote Standing", color=discord.Color.orange()) embed.add_field(name="Target", value=user.mention, inline=True) embed.add_field(name="Score", value=f"{score}/{VOTE_XCOM_THRESHOLD}", inline=True) embed.add_field(name="Distinct voters", value=f"{unique_voters}/{VOTE_MIN_UNIQUE_VOTERS}", inline=True) embed.add_field(name="From human votes", value=str(score - kw_points), inline=True) embed.add_field(name="From keyword flags", value=str(kw_points), inline=True) embed.set_footer(text=f"Threshold {VOTE_XCOM_THRESHOLD} • quorum {VOTE_MIN_UNIQUE_VOTERS} • resets weekly") await interaction.response.send_message(embed=embed, ephemeral=True) @app_commands.command(name="xcom-keywords", description="Manage auto-score keywords/phrases (admin panel)") @app_commands.checks.has_permissions(manage_guild=True) async def xcom_keywords(self, interaction: discord.Interaction): """Open the interactive panel to add, edit, or remove auto-score keywords.""" view = KeywordManagerView(interaction.user.id) await interaction.response.send_message( embed=_keyword_list_embed(interaction.guild), view=view, ephemeral=True ) @xcom.error @xcom_release.error @xcom_unmark.error @xcom_vow.error @xcom_witness.error @xcom_absolve.error @xcom_setup.error @xcom_list.error @xcom_vote.error @xcom_votes.error @xcom_keywords.error async def permission_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError): try: if isinstance(error, app_commands.MissingPermissions): if not interaction.response.is_done(): await interaction.response.send_message( "You need the 'Manage Roles' (or Manage Server for setup) permission to use this command.", ephemeral=True ) else: await interaction.followup.send( "You need the 'Manage Roles' (or Manage Server for setup) permission to use this command.", ephemeral=True ) elif isinstance(error, app_commands.CommandOnCooldown): msg = f"⏳ Slow down — try again in {error.retry_after:.1f}s." if not interaction.response.is_done(): await interaction.response.send_message(msg, ephemeral=True) else: await interaction.followup.send(msg, ephemeral=True) else: if not interaction.response.is_done(): await interaction.response.send_message(f"Error: {error}", ephemeral=True) else: await interaction.followup.send(f"Error: {error}", ephemeral=True) except discord.errors.InteractionResponded: pass # Interaction was already responded to async def setup(bot: commands.Bot): """Load the cog.""" await bot.add_cog(ModerationCog(bot)) print("[COG] Moderation (XCOM) loaded.")