Files
Excommunicado/cogs/moderation.py
T

894 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.")