Files
2026-06-09 07:39:43 -04:00

270 lines
12 KiB
Python

"""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
import logging
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,
get_keywords,
add_keyword_points,
clear_votes,
check_writable,
)
from cogs.moderation import (
get_or_create_xcom_role,
get_or_create_xcom_channel,
get_or_create_xcom_mod_channel,
_post_to_mod_channel,
VOTE_XCOM_THRESHOLD,
VOTE_RESET_DAYS,
)
logger = logging.getLogger("excommunicado.bot")
# 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")
logger.info("Loaded 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)
synced = await self.tree.sync(guild=guild)
logger.info("Synced %d commands to guild %s: %s",
len(synced), guild_id, ", ".join(c.name for c in synced))
else:
synced = await self.tree.sync()
logger.warning(
"GUILD_ID not set: synced %d GLOBAL commands (may take up to 1h to appear): %s",
len(synced), ", ".join(c.name for c in synced),
)
log_id = os.getenv("LOG_CHANNEL_ID")
if log_id:
self.log_channel_id = int(log_id)
logger.info("Log channel configured: %s", log_id)
async def on_ready(self) -> None:
ok, msg = check_writable()
if ok:
logger.info(msg)
else:
logger.error(msg)
logger.info("Logged in as %s (ID: %s)", self.user, self.user.id)
logger.info("Excommunicado bot is ready (XCOM stealth isolation mode).")
print(f"✅ Logged in as {self.user} (ID: {self.user.id})")
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:
content = message.content or "*(no text content)*"
prefix = f"**[{message.channel.name}]** {message.author.mention}: "
# Discord hard limit is 2000 chars; truncate to stay under it
max_content = 2000 - len(prefix) - 1
if len(content) > max_content:
content = content[: max_content - 1] + "…"
await ch.send(
prefix + content,
allowed_mentions=discord.AllowedMentions.none(),
)
except Exception:
pass
return # do not count floods or process commands from isolated users
now = datetime.now(timezone.utc)
# --- Admin-curated keyword / key-phrase auto-scoring ---
# Captures critical abuse (e.g. trading cracked software/games, illegal
# materials) even when no admin is online. Staff/owner are immune.
author = message.author
is_staff = isinstance(author, discord.Member) and (
author.guild_permissions.manage_roles
or author.guild_permissions.administrator
or author.id == message.guild.owner_id
)
keywords = guild_data.get("keywords", {})
if keywords and not is_staff and message.content:
content_low = message.content.lower()
matched_weight = 0
matched_terms = []
for phrase, info in keywords.items():
if phrase and phrase in content_low:
matched_weight += int(info.get("weight", 1))
matched_terms.append(info.get("display", phrase))
if matched_weight > 0:
try:
result = add_keyword_points(
message.guild.id, author.id, matched_weight, reset_days=VOTE_RESET_DAYS
)
logger.info(
"Keyword match: user=%s terms=%s (+%d) -> score=%d guild=%s",
author, matched_terms, matched_weight, result["score"], message.guild.name,
)
me = message.guild.me
can_manage = me is not None and author.top_role < me.top_role
if result["score"] >= VOTE_XCOM_THRESHOLD and can_manage:
cog = self.get_cog("ModerationCog")
if cog is not None:
reason = "Auto-flagged keywords: " + ", ".join(matched_terms[:5])
channel, already = await cog._perform_xcom(
message.guild, author, reason, "keyword auto-detect"
)
clear_votes(message.guild.id, author.id)
log_embed = discord.Embed(
title="🚩 Keyword Auto-XCOM",
description=f"{author.mention} ({author.id}) was auto-XCOM'd into {channel.mention} after matching flagged keywords.",
color=discord.Color.dark_red(),
timestamp=now,
)
log_embed.add_field(name="Matched", value=", ".join(matched_terms[:10]) or "—", inline=False)
await _post_to_mod_channel(message.guild, None, log_embed)
await self.log_action(message.guild, log_embed)
logger.warning(
"Keyword auto-XCOM: user=%s guild=%s score=%d",
author, message.guild.name, result["score"],
)
return
except Exception as kw_err:
logger.exception("Keyword auto-XCOM failed: %s", kw_err)
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}")
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 as err:
logger.warning("Failed to post log embed to channel %s: %s",
self.log_channel_id, err)
def _configure_logging() -> None:
"""Configure root logging from the LOG_LEVEL env var (default INFO)."""
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# discord.py is noisy at DEBUG; keep it at INFO unless explicitly debugging.
logging.getLogger("discord").setLevel(
logging.DEBUG if level <= logging.DEBUG else logging.INFO
)
logger.info("Logging configured at level %s", level_name)
async def main() -> None:
load_dotenv()
_configure_logging()
token = os.getenv("DISCORD_TOKEN")
if not token:
logger.error("DISCORD_TOKEN not set in .env")
return
ok, msg = check_writable()
if not ok:
logger.error(msg)
else:
logger.info(msg)
bot = ExcommunicadoBot()
async with bot:
await bot.start(token)
if __name__ == "__main__":
asyncio.run(main())