patching voting system
This commit is contained in:
+10
-5
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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", {})),
|
||||||
|
|||||||
Reference in New Issue
Block a user