From fc9845772df554807fafd5e98ec8c187c2b83f40 Mon Sep 17 00:00:00 2001 From: Subinacls Date: Mon, 8 Jun 2026 20:47:32 -0400 Subject: [PATCH] Initial commit - Excommunicado Discord bot (XCOM stealth isolation) --- .env.example | 8 + .gitignore | 6 + README.md | 274 +++++++++++++++++++++++++++++++++ bot.py | 159 +++++++++++++++++++ cogs/__init__.py | 1 + cogs/moderation.py | 372 +++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + utils/__init__.py | 1 + utils/config.py | 102 +++++++++++++ 9 files changed, 925 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bot.py create mode 100644 cogs/__init__.py create mode 100644 cogs/moderation.py create mode 100644 requirements.txt create mode 100644 utils/__init__.py create mode 100644 utils/config.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d1dc3fa --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf8d5ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +.env +data/ +*.log +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e751ef --- /dev/null +++ b/README.md @@ -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 [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 ` +- **Permission**: Manage Roles +- **Effects**: Removes role + record. The `[XCOM]` nickname mark **remains** until an admin runs `/xcom-unmark`. + +### `/xcom-unmark ` +- **Permission**: Manage Roles +- **Effects**: Removes the permanent `[XCOM]` nickname prefix. + +### `/xcom-vow ` +- **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. diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..c3cf31b --- /dev/null +++ b/bot.py @@ -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()) diff --git a/cogs/__init__.py b/cogs/__init__.py new file mode 100644 index 0000000..97a5551 --- /dev/null +++ b/cogs/__init__.py @@ -0,0 +1 @@ +"""Cogs for the Discord bot.""" diff --git a/cogs/moderation.py b/cogs/moderation.py new file mode 100644 index 0000000..69593d1 --- /dev/null +++ b/cogs/moderation.py @@ -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.") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9ef41ba --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +discord.py>=2.4.0 +python-dotenv>=1.0.0 diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..cefe41a --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules for Excommunicado bot.""" diff --git a/utils/config.py b/utils/config.py new file mode 100644 index 0000000..f8a9051 --- /dev/null +++ b/utils/config.py @@ -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", {})