Initial commit - Excommunicado Discord bot (XCOM stealth isolation)
This commit is contained in:
@@ -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=
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.env
|
||||
data/
|
||||
*.log
|
||||
.venv/
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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.")
|
||||
@@ -0,0 +1,2 @@
|
||||
discord.py>=2.4.0
|
||||
python-dotenv>=1.0.0
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility modules for Excommunicado bot."""
|
||||
+102
@@ -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", {})
|
||||
Reference in New Issue
Block a user