From 154735e1334732930638f5eadaaf245698429c23 Mon Sep 17 00:00:00 2001 From: Subinacls Date: Tue, 9 Jun 2026 07:22:08 -0400 Subject: [PATCH] patching voting system --- Dockerfile | 15 ++++++++----- bot.py | 56 +++++++++++++++++++++++++++++++++++++++------- docker-compose.yml | 11 ++++++++- utils/config.py | 50 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 116 insertions(+), 16 deletions(-) diff --git a/Dockerfile b/Dockerfile index 73190c9..f640b95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,11 @@ # Dockerfile for Excommunicado Discord Bot 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 WORKDIR /app @@ -13,11 +18,11 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy the rest of the application COPY . . -# Create data directory for persistence -RUN mkdir -p /app/data - -# Create non-root user for security -RUN useradd -m botuser && chown -R botuser:botuser /app +# Create non-root user and give it ownership of the app + data directory. +# UID/GID 1000 is referenced by the named-volume / chown instructions in the README. +RUN useradd -m -u 1000 botuser \ + && mkdir -p /app/data \ + && chown -R botuser:botuser /app USER botuser # Run the bot diff --git a/bot.py b/bot.py index 97b8f8c..d72fad5 100644 --- a/bot.py +++ b/bot.py @@ -5,6 +5,7 @@ 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 @@ -24,6 +25,7 @@ from utils.config import ( get_keywords, add_keyword_points, clear_votes, + check_writable, ) from cogs.moderation import ( get_or_create_xcom_role, @@ -32,6 +34,8 @@ from cogs.moderation import ( 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 @@ -51,24 +55,36 @@ class ExcommunicadoBot(commands.Bot): 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) - await self.tree.sync(guild=guild) - print(f"[INFO] Synced commands to guild {guild_id}") + 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: - await self.tree.sync() - print("[INFO] Synced global commands (may take up to 1h)") + 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})") - print("Excommunicado bot is ready (XCOM stealth isolation mode).") async def on_message(self, message: discord.Message) -> None: """Stealth isolation + flood detection. @@ -202,17 +218,41 @@ class ExcommunicadoBot(commands.Bot): if log_channel: try: await log_channel.send(embed=embed) - except Exception: - pass + 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: - print("ERROR: DISCORD_TOKEN not set in .env") + 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) diff --git a/docker-compose.yml b/docker-compose.yml index e351563..4e1f889 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,14 @@ services: restart: unless-stopped env_file: - .env + environment: + # Set LOG_LEVEL=DEBUG for verbose troubleshooting output + - LOG_LEVEL=${LOG_LEVEL:-INFO} 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: driver: "json-file" options: @@ -18,3 +24,6 @@ services: timeout: 10s retries: 3 start_period: 10s + +volumes: + excommunicado-data: diff --git a/utils/config.py b/utils/config.py index 0ae808b..2bd5105 100644 --- a/utils/config.py +++ b/utils/config.py @@ -1,5 +1,6 @@ """Configuration and persistence for the Excommunicado bot.""" import json +import logging import os import tempfile import threading @@ -7,6 +8,8 @@ from datetime import datetime, timezone, timedelta from pathlib import Path from typing import Any, Dict, Optional, Tuple +logger = logging.getLogger("excommunicado.config") + DATA_DIR = Path(__file__).parent.parent / "data" CONFIG_FILE = DATA_DIR / "xcom_config.json" @@ -20,6 +23,27 @@ def _ensure_data_dir() -> None: 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]: """Load the entire config dict (cached after first read).""" global _cache @@ -53,13 +77,26 @@ def save_config(data: Dict[str, Any]) -> None: with _lock: _ensure_data_dir() # 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: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) f.flush() os.fsync(f.fileno()) os.replace(tmp_path, CONFIG_FILE) + logger.debug("Config saved (%d guild entries) to %s", len(data), CONFIG_FILE) except BaseException: if os.path.exists(tmp_path): os.remove(tmp_path) @@ -231,10 +268,15 @@ def cast_vote( "timestamp": now.isoformat(), } 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 { "ok": True, "reason": None, - "score": _record_score(rec), + "score": score, "unique_voters": len(voters), "retry_hours": None, } @@ -298,6 +340,10 @@ def add_keyword_points( rec["keyword_points"] = int(rec.get("keyword_points", 0)) + int(amount) 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 { "score": _record_score(rec), "unique_voters": len(rec.get("voters", {})),