patching voting system

This commit is contained in:
Subinacls
2026-06-09 07:22:08 -04:00
parent a789fb131f
commit 154735e133
4 changed files with 116 additions and 16 deletions
+10 -5
View File
@@ -1,6 +1,11 @@
# Dockerfile for Excommunicado Discord Bot # Dockerfile for Excommunicado Discord Bot
FROM python:3.12-slim FROM python:3.12-slim
# Unbuffered stdout/stderr so logs appear immediately in `docker logs`
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
LOG_LEVEL=INFO
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@@ -13,11 +18,11 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application # Copy the rest of the application
COPY . . COPY . .
# Create data directory for persistence # Create non-root user and give it ownership of the app + data directory.
RUN mkdir -p /app/data # UID/GID 1000 is referenced by the named-volume / chown instructions in the README.
RUN useradd -m -u 1000 botuser \
# Create non-root user for security && mkdir -p /app/data \
RUN useradd -m botuser && chown -R botuser:botuser /app && chown -R botuser:botuser /app
USER botuser USER botuser
# Run the bot # Run the bot
+48 -8
View File
@@ -5,6 +5,7 @@ private 'xcom-lounge' channels without banning them from the server.
""" """
import os import os
import asyncio import asyncio
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from collections import defaultdict, deque from collections import defaultdict, deque
from typing import Deque, Dict from typing import Deque, Dict
@@ -24,6 +25,7 @@ from utils.config import (
get_keywords, get_keywords,
add_keyword_points, add_keyword_points,
clear_votes, clear_votes,
check_writable,
) )
from cogs.moderation import ( from cogs.moderation import (
get_or_create_xcom_role, get_or_create_xcom_role,
@@ -32,6 +34,8 @@ from cogs.moderation import (
VOTE_RESET_DAYS, VOTE_RESET_DAYS,
) )
logger = logging.getLogger("excommunicado.bot")
# Simple in-memory rate limiting for flood detection (user_id -> deque of timestamps) # 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)) USER_MESSAGE_TIMES: Dict[int, Deque[datetime]] = defaultdict(lambda: deque(maxlen=20))
FLOOD_THRESHOLD = 5 # messages FLOOD_THRESHOLD = 5 # messages
@@ -51,24 +55,36 @@ class ExcommunicadoBot(commands.Bot):
async def setup_hook(self) -> None: async def setup_hook(self) -> None:
"""Load cogs and sync slash commands.""" """Load cogs and sync slash commands."""
await self.load_extension("cogs.moderation") await self.load_extension("cogs.moderation")
logger.info("Loaded extension: cogs.moderation")
guild_id = os.getenv("GUILD_ID") guild_id = os.getenv("GUILD_ID")
if guild_id: if guild_id:
guild = discord.Object(id=int(guild_id)) guild = discord.Object(id=int(guild_id))
self.tree.copy_global_to(guild=guild) self.tree.copy_global_to(guild=guild)
await self.tree.sync(guild=guild) synced = await self.tree.sync(guild=guild)
print(f"[INFO] Synced commands to guild {guild_id}") logger.info("Synced %d commands to guild %s: %s",
len(synced), guild_id, ", ".join(c.name for c in synced))
else: else:
await self.tree.sync() synced = await self.tree.sync()
print("[INFO] Synced global commands (may take up to 1h)") 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") log_id = os.getenv("LOG_CHANNEL_ID")
if log_id: if log_id:
self.log_channel_id = int(log_id) self.log_channel_id = int(log_id)
logger.info("Log channel configured: %s", log_id)
async def on_ready(self) -> None: 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})") 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: async def on_message(self, message: discord.Message) -> None:
"""Stealth isolation + flood detection. """Stealth isolation + flood detection.
@@ -202,17 +218,41 @@ class ExcommunicadoBot(commands.Bot):
if log_channel: if log_channel:
try: try:
await log_channel.send(embed=embed) await log_channel.send(embed=embed)
except Exception: except Exception as err:
pass 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: async def main() -> None:
load_dotenv() load_dotenv()
_configure_logging()
token = os.getenv("DISCORD_TOKEN") token = os.getenv("DISCORD_TOKEN")
if not token: if not token:
print("ERROR: DISCORD_TOKEN not set in .env") logger.error("DISCORD_TOKEN not set in .env")
return return
ok, msg = check_writable()
if not ok:
logger.error(msg)
else:
logger.info(msg)
bot = ExcommunicadoBot() bot = ExcommunicadoBot()
async with bot: async with bot:
await bot.start(token) await bot.start(token)
+10 -1
View File
@@ -5,8 +5,14 @@ services:
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
environment:
# Set LOG_LEVEL=DEBUG for verbose troubleshooting output
- LOG_LEVEL=${LOG_LEVEL:-INFO}
volumes: volumes:
- ./data:/app/data # Named volume avoids host bind-mount ownership problems with the
# non-root botuser (UID 1000). Data persists in the 'excommunicado-data'
# Docker volume. To inspect it: docker run --rm -v excommunicado-data:/d alpine ls -la /d
- excommunicado-data:/app/data
logging: logging:
driver: "json-file" driver: "json-file"
options: options:
@@ -18,3 +24,6 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 10s start_period: 10s
volumes:
excommunicado-data:
+48 -2
View File
@@ -1,5 +1,6 @@
"""Configuration and persistence for the Excommunicado bot.""" """Configuration and persistence for the Excommunicado bot."""
import json import json
import logging
import os import os
import tempfile import tempfile
import threading import threading
@@ -7,6 +8,8 @@ from datetime import datetime, timezone, timedelta
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger("excommunicado.config")
DATA_DIR = Path(__file__).parent.parent / "data" DATA_DIR = Path(__file__).parent.parent / "data"
CONFIG_FILE = DATA_DIR / "xcom_config.json" CONFIG_FILE = DATA_DIR / "xcom_config.json"
@@ -20,6 +23,27 @@ def _ensure_data_dir() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
def check_writable() -> Tuple[bool, str]:
"""Verify the data directory is writable. Returns (ok, message).
Useful at startup to fail loudly when a Docker volume is owned by the
wrong user (the classic non-root + bind-mount permission problem).
"""
try:
_ensure_data_dir()
probe = DATA_DIR / ".write_test"
with open(probe, "w", encoding="utf-8") as f:
f.write("ok")
probe.unlink(missing_ok=True)
return True, f"data dir writable: {DATA_DIR} (uid={os.getuid() if hasattr(os, 'getuid') else 'n/a'})"
except OSError as err:
uid = os.getuid() if hasattr(os, "getuid") else "n/a"
return False, (
f"DATA DIR NOT WRITABLE: {DATA_DIR} (process uid={uid}). "
f"Fix volume ownership or run: chown -R 1000:1000 ./data ({err})"
)
def load_config() -> Dict[str, Any]: def load_config() -> Dict[str, Any]:
"""Load the entire config dict (cached after first read).""" """Load the entire config dict (cached after first read)."""
global _cache global _cache
@@ -53,13 +77,26 @@ def save_config(data: Dict[str, Any]) -> None:
with _lock: with _lock:
_ensure_data_dir() _ensure_data_dir()
# Write to a temp file in the same dir, then atomically replace. # Write to a temp file in the same dir, then atomically replace.
fd, tmp_path = tempfile.mkstemp(dir=str(DATA_DIR), suffix=".tmp") # NOTE: temp file must be on the SAME filesystem as CONFIG_FILE for
# os.replace to be atomic, so it lives in DATA_DIR (not /tmp).
try:
fd, tmp_path = tempfile.mkstemp(dir=str(DATA_DIR), suffix=".tmp")
except OSError as err:
uid = os.getuid() if hasattr(os, "getuid") else "n/a"
logger.error(
"Cannot create temp file in %s (uid=%s): %s. "
"The data volume is likely owned by another user. "
"Run: chown -R 1000:1000 ./data (or switch to a named volume).",
DATA_DIR, uid, err,
)
raise
try: try:
with os.fdopen(fd, "w", encoding="utf-8") as f: with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False) json.dump(data, f, indent=2, ensure_ascii=False)
f.flush() f.flush()
os.fsync(f.fileno()) os.fsync(f.fileno())
os.replace(tmp_path, CONFIG_FILE) os.replace(tmp_path, CONFIG_FILE)
logger.debug("Config saved (%d guild entries) to %s", len(data), CONFIG_FILE)
except BaseException: except BaseException:
if os.path.exists(tmp_path): if os.path.exists(tmp_path):
os.remove(tmp_path) os.remove(tmp_path)
@@ -231,10 +268,15 @@ def cast_vote(
"timestamp": now.isoformat(), "timestamp": now.isoformat(),
} }
save_config(config) save_config(config)
score = _record_score(rec)
logger.info(
"Vote recorded: guild=%s target=%s voter=%s dir=%+d weight=%d -> score=%d voters=%d",
gid, tid, vid, direction, int(weight), score, len(voters),
)
return { return {
"ok": True, "ok": True,
"reason": None, "reason": None,
"score": _record_score(rec), "score": score,
"unique_voters": len(voters), "unique_voters": len(voters),
"retry_hours": None, "retry_hours": None,
} }
@@ -298,6 +340,10 @@ def add_keyword_points(
rec["keyword_points"] = int(rec.get("keyword_points", 0)) + int(amount) rec["keyword_points"] = int(rec.get("keyword_points", 0)) + int(amount)
save_config(config) save_config(config)
logger.info(
"Keyword points added: guild=%s target=%s amount=%+d -> keyword_points=%d score=%d",
gid, tid, int(amount), rec["keyword_points"], _record_score(rec),
)
return { return {
"score": _record_score(rec), "score": _record_score(rec),
"unique_voters": len(rec.get("voters", {})), "unique_voters": len(rec.get("voters", {})),