Initial commit - Excommunicado Discord bot (XCOM stealth isolation)

This commit is contained in:
Subinacls
2026-06-08 20:47:32 -04:00
commit fc9845772d
9 changed files with 925 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Discord Bot Token from https://discord.com/developers/applications
DISCORD_TOKEN=your_bot_token_here
# Guild ID for testing (optional, for faster command sync in dev)
GUILD_ID=your_guild_id_here
# Log channel ID for Excommunicado / XCOM actions (optional)
LOG_CHANNEL_ID=
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
.env
data/
*.log
.venv/
+274
View File
@@ -0,0 +1,274 @@
# Excommunicado
**Excommunicado** is a Discord moderation bot designed to combat channel flooding and spam without resorting to bans or kicks.
In **stealth mode**, offending users **do not realize** they have been moved. They continue to see the entire server normally. Their public messages are silently deleted and forwarded to the shared **XCOM Lounge** room, where selected staff members can also be present to converse with them. Normal members never see the spam or the moderated room. This creates the illusion of a normal experience while containing the violation.
## Table of Contents
- [Features](#features)
- [Philosophy & Use Cases](#philosophy--use-cases)
- [Requirements](#requirements)
- [Installation & Quick Start](#installation--quick-start)
- [Detailed Command Reference](#detailed-command-reference)
- [Configuration](#configuration)
- [How Isolation Works (Technical)](#how-isolation-works-technical)
- [Architecture & Code Structure](#architecture--code-structure)
- [Customization](#customization)
- [Troubleshooting](#troubleshooting)
- [Security Best Practices](#security-best-practices)
- [Extending the Bot](#extending-the-bot)
- [License](#license)
## Features
- **Slash commands only** (modern Discord UX)
- `/xcom-setup` one-time server initialization
- `/xcom @user [reason]` excommunicate a user (manual or via auto-detection)
- `/xcom-release @user` restore full access
- `/xcom-list` view all currently XCOM users with reasons and timestamps
- **Automatic flood protection** detects ≥5 messages within 8 seconds and auto-excommunicates
- **Absolution Points** users earn points for good behavior in the xcom-lounge; staff can absolve early
- **Shared hidden channel** `xcom-lounge` with 5-second slowmode
- **Persistent state** JSON storage survives bot restarts
- **Rich embeds + DM notifications** for offenders and staff logs
- **Graceful permission handling** and role hierarchy checks
- Zero bans/kicks users stay in the server
## Philosophy & Use Cases
Excommunicado solves the "ban vs. mute" dilemma for flooders:
- Bans encourage alt accounts.
- Timeouts/mutes still allow the user to see the server and feel excluded.
- Excommunication gives the user a dedicated space to talk to moderators while completely removing their ability to disrupt public channels.
Ideal for:
- High-traffic community servers
- Gaming clans with strict chat rules
- Support servers where users sometimes vent excessively
- Any server that values retention over punishment
## Requirements
- Python ≥ 3.10
- discord.py ≥ 2.4.0 + python-dotenv
- Discord bot with **privileged intents** enabled:
- Server Members Intent
- Message Content Intent
- Bot permissions on invite: `Manage Roles`, `Manage Channels`, `Send Messages`, `Embed Links`, `Read Message History` (Administrator recommended for initial setup)
- Role hierarchy: Bot role must sit **above** the `Excommunicado` role
## Installation & Quick Start
### 1. Prepare the project
```
cd ~/Excommunicado # or your chosen folder
```
### 2. Install dependencies
```
pip install -r requirements.txt
```
### 3. Configure environment
cp `.env.example``.env` and populate:
```env
DISCORD_TOKEN=your_token_here
GUILD_ID=123456789012345678 # optional speeds up command registration during development
LOG_CHANNEL_ID=987654321098765432 # optional receives audit embeds
```
### 4. Create & invite the bot (Discord Developer Portal)
1. https://discord.com/developers/applications → New Application → Bot
2. Enable the two privileged intents under the Bot tab
3. Generate a token (copy to `.env`)
4. OAuth2 → URL Generator → `bot` + `applications.commands` scopes + required permissions
5. Invite the bot to your target guild
### 5. Run the bot
```powershell
python bot.py
```
You should see:
```
✅ Logged in as Excommunicado#1234 ...
Excommunicado bot is ready (XCOM stealth isolation mode).
[COG] Moderation (Excommunicado) loaded.
```
### 6. Run `/xcom-setup` in Discord
(Requires Manage Server permission)
This creates:
- `Excommunicado` role (dark red color)
- `xcom-lounge` text channel (hidden, slowmode 5s)
### 7. Manual role lockdown (critical for older non-stealth setups)
After setup, the `Excommunicado` role is only a marker. Do **not** deny View on public channels (stealth mode keeps the user seeing the full server). The `xcom-lounge` is already hidden from @everyone.
### 8. Test the flow
```
/xcom @SomeSpammer Flooding with repeated messages
```
The user is stealthily XCOM'd. Their messages are forwarded to the shared room. They receive a DM and a staff log is sent (if configured).
## Detailed Command Reference
### `/xcom-setup`
- **Permission**: Manage Server
- **Purpose**: Idempotent creation of role + channel
- **Output**: Embed explaining next manual steps
### `/xcom <user> [reason]`
- **Permission**: Manage Roles
- **Parameters**:
- `user` (required) target member
- `reason` (optional) default "Flooding the channel"
- **Effects**:
- Assigns `Excommunicado` role
- Records in `data/xcom_config.json`
- Posts welcome embed in `xcom-lounge`
- DMs the user
- Logs to staff channel (if set)
- **Edge cases**: Already under XCOM, bot, self-target, missing hierarchy → clear ephemeral error
### `/xcom-release <user>`
- **Permission**: Manage Roles
- **Effects**: Removes role + record. The `[XCOM]` nickname mark **remains** until an admin runs `/xcom-unmark`.
### `/xcom-unmark <user>`
- **Permission**: Manage Roles
- **Effects**: Removes the permanent `[XCOM]` nickname prefix.
### `/xcom-vow <text>`
- **Permission**: None (user must be under XCOM)
- **Effects**: Posts the user's vow in the xcom-lounge for staff to see.
### `/xcom-witness @staff @target`
- **Permission**: Manage Roles
- **Effects**: Gives the staff member the Excommunicado role so they can join the shared lounge.
### `/xcom-absolve @user`
- **Permission**: Manage Roles
- **Effects**: If the user has ≥10 absolution points (earned by positive participation in the lounge), removes the role early. The mark remains.
### `/xcom-list`
- **Permission**: Manage Roles
- **Output**: Embed listing up to 15 users with reason + date (truncated if more)
All commands are ephemeral where possible to keep chat clean.
## Configuration
### Environment Variables
| Variable | Required | Description |
|-------------------|----------|------------------------------------------|
| `DISCORD_TOKEN` | Yes | Bot token |
| `GUILD_ID` | No | Guild for faster slash command sync |
| `LOG_CHANNEL_ID` | No | Channel that receives action embeds |
### Tunable Constants (in `bot.py`)
```python
FLOOD_THRESHOLD = 5
FLOOD_WINDOW_SECONDS = 5
```
Change and restart to adjust sensitivity.
### Data File
`data/xcom_config.json` (auto-created):
```json
{
"123456789": {
"xcom_role_id": 111111111,
"xcom_channel_id": 222222222,
"isolated_users": {
"333333333": {
"reason": "Auto-detected flooding...",
"timestamp": "2026-06-08T12:34:56.789012+00:00"
}
}
}
}
```
## How Isolation Works (Technical) — Stealth Mode
1. On `/xcom-setup`, the bot creates:
- `Excommunicado` role (marker only)
- `xcom-lounge` channel (hidden from @everyone, visible to Excommunicado role holders)
2. When a user is excommunicated (manually or automatically):
- They receive the `Excommunicado` role (grants access to the shared room)
- **No view permissions are removed** from public channels — the user continues to see the full server and does not realize anything changed.
3. On every message the isolated user sends outside `xcom-lounge`:
- The message is **instantly deleted** (public never sees the spam)
- The content is forwarded to the shared `xcom-lounge` room
- Staff members who also hold the `Excommunicado` role can see and reply in the same room
4. The offender experiences normal server browsing + "their messages just go to the moderated lounge where staff are"
5. `/xcom-release` removes the role — full normal behavior is restored instantly
This design ensures the violation holder shares the room with real staff members while never realizing they have been isolated.
## Architecture & Code Structure
```
Excommunicado/
├── bot.py # Entry point, flood detector (on_message), ExcommunicadoBot class
├── requirements.txt
├── .env.example
├── README.md # This file (full documentation)
├── utils/
│ ├── config.py # JSON load/save helpers + isolated user CRUD
│ └── __init__.py
├── cogs/
│ ├── moderation.py # All slash commands + helper functions
│ └── __init__.py
└── data/
└── xcom_config.json
```
Key flows:
- `on_message` → rate-limit deque → auto call to `get_or_create_*` + `add_roles`
- Commands → permission checks → role/channel helpers → config persistence → DM + embed
## Customization
- Change role name/color in `get_or_create_xcom_role`
- Change channel name/slowmode in `get_or_create_xcom_channel`
- Add more commands by creating additional cogs
- Integrate with existing mod bots via webhooks or shared log channel
## Troubleshooting
- **"Missing Permissions" on role assign** → Bot role not high enough in hierarchy
- **Commands not appearing** → Restart bot after `GUILD_ID` change; wait 1h for global sync
- **Isolated user messages still appear publicly** → The stealth suppression logic in on_message is not triggering (check that the user has the Excommunicado role or is in the isolated_users JSON)
- **Auto-detection too sensitive** → Increase `FLOOD_WINDOW_SECONDS` or threshold
- **JSON corrupted** → Delete `data/xcom_config.json` (bot will recreate)
## Security Best Practices
- Never commit `.env` or the token
- Use a dedicated bot account (not your personal account)
- Give the bot the minimum permissions required
- Regularly audit `data/xcom_config.json` for old entries
- Give trusted staff the `Excommunicado` role so they can share the moderated room with XCOM users and respond naturally
## Extending the Bot
Example: add a `/xcom-stats` command counting total excommunications.
1. Add new `@app_commands.command` in `moderation.py`
2. Use `get_isolated_users` to aggregate data
3. Register in `setup()`
The modular cog design makes extension trivial.
## License
Internal moderation tool. Free to modify and use within your communities.
---
**Excommunicado** Excommunicate the flood, keep the member.
```
(End of full documentation)
Made to keep your server clean without destroying community members.
```
## Usage Examples
- `/xcom @Spammer42 Spamming links and flooding`
- `/xcom-list`
- `/xcom-release @Spammer42`
The user is now under XCOM in the shared moderated room — problem contained.
+159
View File
@@ -0,0 +1,159 @@
"""Main entry point for the Excommunicado Discord bot.
Excommunicado prevents flooding by isolating (excommunicating) offenders into
private 'xcom-lounge' channels without banning them from the server.
"""
import os
import asyncio
from datetime import datetime, timezone
from collections import defaultdict, deque
from typing import Deque, Dict
import discord
from discord.ext import commands
from discord import app_commands
from dotenv import load_dotenv
from utils.config import (
get_guild_data,
update_guild_data,
add_isolated_user,
remove_isolated_user,
get_isolated_users,
increment_absolution_points,
)
from cogs.moderation import get_or_create_xcom_role, get_or_create_xcom_channel
# Simple in-memory rate limiting for flood detection (user_id -> deque of timestamps)
USER_MESSAGE_TIMES: Dict[int, Deque[datetime]] = defaultdict(lambda: deque(maxlen=20))
FLOOD_THRESHOLD = 5 # messages
FLOOD_WINDOW_SECONDS = 8 # within this many seconds
class ExcommunicadoBot(commands.Bot):
def __init__(self):
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
intents.guilds = True
super().__init__(command_prefix="!", intents=intents, help_command=None)
self.log_channel_id = None # set from env if provided
async def setup_hook(self) -> None:
"""Load cogs and sync slash commands."""
await self.load_extension("cogs.moderation")
guild_id = os.getenv("GUILD_ID")
if guild_id:
guild = discord.Object(id=int(guild_id))
self.tree.copy_global_to(guild=guild)
await self.tree.sync(guild=guild)
print(f"[INFO] Synced commands to guild {guild_id}")
else:
await self.tree.sync()
print("[INFO] Synced global commands (may take up to 1h)")
log_id = os.getenv("LOG_CHANNEL_ID")
if log_id:
self.log_channel_id = int(log_id)
async def on_ready(self) -> None:
print(f"✅ Logged in as {self.user} (ID: {self.user.id})")
print("Excommunicado bot is ready (XCOM stealth isolation mode).")
async def on_message(self, message: discord.Message) -> None:
"""Stealth isolation + flood detection.
Isolated users keep seeing the normal server (no view permission changes).
Their public messages are silently deleted and forwarded to the shared
xcom-lounge room so they do not realize they have been moved.
Trusted staff can also hold the Excommunicado role to share the room with them.
"""
if message.author.bot or not message.guild:
return
guild_data = get_guild_data(message.guild.id)
isolated_users = guild_data.get("isolated_users", {})
xcom_role_id = guild_data.get("xcom_role_id")
xcom_channel_id = guild_data.get("xcom_channel_id")
is_isolated = str(message.author.id) in isolated_users or (
xcom_role_id and any(r.id == xcom_role_id for r in getattr(message.author, "roles", []))
)
if is_isolated:
# Award absolution points for positive participation in the xcom-lounge
if xcom_channel_id and message.channel.id == xcom_channel_id:
increment_absolution_points(message.guild.id, message.author.id, 1)
# Stealth mode: silently relocate message to the shared room
if message.channel.id != xcom_channel_id:
try:
await message.delete()
if xcom_channel_id:
ch = message.guild.get_channel(xcom_channel_id)
if ch:
await ch.send(
f"**[{message.channel.name}]** {message.author.mention}: {message.content}"
)
except Exception:
pass
return # do not count floods or process commands from isolated users
now = datetime.now(timezone.utc)
times = USER_MESSAGE_TIMES[message.author.id]
times.append(now)
# Clean old entries
while times and (now - times[0]).total_seconds() > FLOOD_WINDOW_SECONDS:
times.popleft()
if len(times) >= FLOOD_THRESHOLD:
# Flood detected - auto XCOM the offender into xcom-lounge
print(f"[FLOOD] Detected from {message.author} in {message.guild.name} - auto-XCOM'ing")
try:
role = await get_or_create_xcom_role(message.guild)
channel = await get_or_create_xcom_channel(message.guild, role)
if role not in message.author.roles:
await message.author.add_roles(role, reason="Auto-XCOM'd: flooding detected")
add_isolated_user(
message.guild.id,
message.author.id,
"Auto-detected flooding (rapid messages)",
now.isoformat()
)
await channel.send(
f"{message.author.mention} has been auto-XCOM'd for flooding the channel.\n"
"You are now chatting in the moderated shared room. Staff are present."
)
except Exception as flood_err:
print(f"[FLOOD] Auto-XCOM failed: {flood_err}")
await self.process_commands(message)
async def log_action(self, guild: discord.Guild, embed: discord.Embed) -> None:
"""Send log to configured log channel if set."""
if self.log_channel_id:
log_channel = guild.get_channel(self.log_channel_id)
if log_channel:
try:
await log_channel.send(embed=embed)
except Exception:
pass
async def main() -> None:
load_dotenv()
token = os.getenv("DISCORD_TOKEN")
if not token:
print("ERROR: DISCORD_TOKEN not set in .env")
return
bot = ExcommunicadoBot()
async with bot:
await bot.start(token)
if __name__ == "__main__":
asyncio.run(main())
+1
View File
@@ -0,0 +1 @@
"""Cogs for the Discord bot."""
+372
View File
@@ -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.")
+2
View File
@@ -0,0 +1,2 @@
discord.py>=2.4.0
python-dotenv>=1.0.0
+1
View File
@@ -0,0 +1 @@
"""Utility modules for Excommunicado bot."""
+102
View File
@@ -0,0 +1,102 @@
"""Configuration and persistence for the Excommunicado bot."""
import json
from pathlib import Path
from typing import Any, Dict, Optional
DATA_DIR = Path(__file__).parent.parent / "data"
CONFIG_FILE = DATA_DIR / "xcom_config.json"
def _ensure_data_dir() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
def load_config() -> Dict[str, Any]:
"""Load the entire config dict."""
_ensure_data_dir()
if not CONFIG_FILE.exists():
save_config({})
return {}
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
return json.load(f)
def save_config(data: Dict[str, Any]) -> None:
"""Save the config dict."""
_ensure_data_dir()
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def get_guild_data(guild_id: int) -> Dict[str, Any]:
"""Get or initialize data for a specific guild."""
config = load_config()
gid = str(guild_id)
if gid not in config:
config[gid] = {
"xcom_role_id": None,
"xcom_channel_id": None,
"isolated_users": {} # user_id -> {"reason": str, "timestamp": str}
}
save_config(config)
return config[gid]
def update_guild_data(guild_id: int, key: str, value: Any) -> None:
"""Update a top-level key for the guild."""
config = load_config()
gid = str(guild_id)
if gid not in config:
config[gid] = {
"xcom_role_id": None,
"xcom_channel_id": None,
"isolated_users": {}
}
config[gid][key] = value
save_config(config)
def add_isolated_user(guild_id: int, user_id: int, reason: str, timestamp: str) -> None:
"""Record a newly isolated user (shared xcom-lounge channel)."""
config = load_config()
gid = str(guild_id)
if gid not in config:
config[gid] = {"xcom_role_id": None, "xcom_channel_id": None, "isolated_users": {}}
config[gid]["isolated_users"][str(user_id)] = {
"reason": reason,
"timestamp": timestamp,
"absolution_points": 0,
"marked": True
}
save_config(config)
def increment_absolution_points(guild_id: int, user_id: int, amount: int = 1) -> int:
"""Increase absolution points for a user and return the new total."""
config = load_config()
gid = str(guild_id)
uid = str(user_id)
if gid in config and uid in config[gid].get("isolated_users", {}):
current = config[gid]["isolated_users"][uid].get("absolution_points", 0)
new_total = current + amount
config[gid]["isolated_users"][uid]["absolution_points"] = new_total
save_config(config)
return new_total
return 0
def remove_isolated_user(guild_id: int, user_id: int) -> Optional[Dict]:
"""Remove user from isolation record and return previous data or None."""
config = load_config()
gid = str(guild_id)
if gid in config and str(user_id) in config[gid].get("isolated_users", {}):
data = config[gid]["isolated_users"].pop(str(user_id))
save_config(config)
return data
return None
def get_isolated_users(guild_id: int) -> Dict[str, Dict]:
"""Return all isolated users for guild."""
data = get_guild_data(guild_id)
return data.get("isolated_users", {})