894 lines
41 KiB
Python
894 lines
41 KiB
Python
"""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.")
|