Initial commit - Excommunicado Discord bot (XCOM stealth isolation)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Cogs for the Discord bot."""
|
||||
@@ -0,0 +1,372 @@
|
||||
"""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 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,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ModerationCog(commands.Cog):
|
||||
"""XCOM commands and logic."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
@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)
|
||||
|
||||
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="Channel Created", value=f"{channel.mention}", 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).\nNormal members cannot see xcom-lounge (already configured).\n\nStaff members you want to 'share the room' with the isolated user can be given the Excommunicado role manually.",
|
||||
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.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
|
||||
|
||||
guild = interaction.guild
|
||||
role = await get_or_create_xcom_role(guild)
|
||||
channel = await get_or_create_xcom_channel(guild, role)
|
||||
|
||||
if role in user.roles:
|
||||
await interaction.response.send_message(f"{user.mention} is already under XCOM.", ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await user.add_roles(role, reason=f"XCOM by {interaction.user}: {reason}")
|
||||
except discord.Forbidden:
|
||||
await interaction.response.send_message("Missing permissions to assign role. Check bot role hierarchy.", ephemeral=True)
|
||||
return
|
||||
|
||||
# Apply permanent XCOM Mark (nickname prefix) - survives release
|
||||
try:
|
||||
current_nick = user.nick or user.name
|
||||
if not current_nick.startswith("[XCOM]"):
|
||||
new_nick = f"[XCOM] {current_nick}"[:32] # Discord nick limit
|
||||
await user.edit(nick=new_nick, reason="XCOM permanent mark applied")
|
||||
except discord.Forbidden:
|
||||
pass # Bot lacks permission to change nickname
|
||||
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
add_isolated_user(guild.id, user.id, reason, timestamp)
|
||||
|
||||
# Welcome message in the isolation channel
|
||||
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
|
||||
|
||||
# DM the user
|
||||
try:
|
||||
await user.send(embed=welcome)
|
||||
except discord.Forbidden:
|
||||
pass # DMs disabled
|
||||
|
||||
# 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.response.send_message(
|
||||
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."""
|
||||
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 currently under XCOM.", ephemeral=True)
|
||||
return
|
||||
|
||||
await user.remove_roles(role, reason=f"Released from XCOM by {interaction.user}")
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"✅ {user.mention} has been released from XCOM and restored to normal access. The [XCOM] mark remains until an admin removes it.",
|
||||
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")
|
||||
xcom_channel_id = data.get("xcom_channel_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
|
||||
|
||||
channel = guild.get_channel(xcom_channel_id) if xcom_channel_id else None
|
||||
if channel:
|
||||
vow_embed = discord.Embed(
|
||||
title="📜 XCOM Vow Submitted",
|
||||
description=f"**{interaction.user.mention}** has made the following vow:\n\n> {vow}",
|
||||
color=discord.Color.dark_red()
|
||||
)
|
||||
await channel.send(embed=vow_embed)
|
||||
await interaction.response.send_message("Your vow has been submitted to staff.", ephemeral=True)
|
||||
else:
|
||||
await interaction.response.send_message("XCOM lounge not found.", 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.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
|
||||
|
||||
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.response.send_message(
|
||||
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)
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"✅ {user.mention} has been absolved ({points} points). The [XCOM] mark remains until manually removed.",
|
||||
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)
|
||||
|
||||
@xcom.error
|
||||
@xcom_release.error
|
||||
@xcom_unmark.error
|
||||
@xcom_vow.error
|
||||
@xcom_witness.error
|
||||
@xcom_absolve.error
|
||||
@xcom_setup.error
|
||||
@xcom_list.error
|
||||
async def permission_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
|
||||
if isinstance(error, app_commands.MissingPermissions):
|
||||
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.response.send_message(f"Error: {error}", ephemeral=True)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
"""Load the cog."""
|
||||
await bot.add_cog(ModerationCog(bot))
|
||||
print("[COG] Moderation (XCOM) loaded.")
|
||||
Reference in New Issue
Block a user