Initial commit: Breehavior Monitor Discord bot

Discord bot for monitoring chat sentiment and tracking drama using
Ollama LLM on athena.lan. Includes sentiment analysis, slash commands,
drama tracking, and SQL Server persistence via Docker Compose.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 22:39:40 -05:00
commit a35705d3f1
15 changed files with 2425 additions and 0 deletions

213
bot.py Normal file
View File

@@ -0,0 +1,213 @@
import asyncio
import logging
import os
import signal
import socket
import sys
import discord
import yaml
from discord.ext import commands
from dotenv import load_dotenv
from utils.database import Database
from utils.drama_tracker import DramaTracker
from utils.ollama_client import LLMClient
# Load .env
load_dotenv()
# Logging
os.makedirs("logs", exist_ok=True)
class SafeStreamHandler(logging.StreamHandler):
"""StreamHandler that replaces unencodable characters instead of crashing."""
def emit(self, record):
try:
msg = self.format(record)
stream = self.stream
stream.write(msg.encode(stream.encoding or "utf-8", errors="replace").decode(stream.encoding or "utf-8", errors="replace") + self.terminator)
self.flush()
except Exception:
self.handleError(record)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
handlers=[
SafeStreamHandler(sys.stdout),
logging.FileHandler("logs/bcs.log", encoding="utf-8"),
],
)
logger = logging.getLogger("bcs")
def load_config() -> dict:
config_path = os.path.join(os.path.dirname(__file__), "config.yaml")
try:
with open(config_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
except FileNotFoundError:
logger.warning("config.yaml not found, using defaults.")
return {}
class BCSBot(commands.Bot):
def __init__(self, config: dict):
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
super().__init__(
command_prefix=config.get("bot", {}).get("prefix", "!"),
intents=intents,
)
self.config = config
# LLM client (OpenAI-compatible — works with llama.cpp, Ollama, or OpenAI)
llm_base_url = os.getenv("LLM_BASE_URL", "http://athena.lan:11434")
llm_model = os.getenv("LLM_MODEL", "Qwen3-VL-32B-Thinking-Q8_0")
llm_api_key = os.getenv("LLM_API_KEY", "not-needed")
self.ollama = LLMClient(llm_base_url, llm_model, llm_api_key)
# Drama tracker
sentiment = config.get("sentiment", {})
timeouts = config.get("timeouts", {})
self.drama_tracker = DramaTracker(
window_size=sentiment.get("rolling_window_size", 10),
window_minutes=sentiment.get("rolling_window_minutes", 15),
offense_reset_minutes=timeouts.get("offense_reset_minutes", 120),
)
# Database (initialized async in setup_hook)
self.db = Database()
async def setup_hook(self):
# Initialize database and hydrate DramaTracker
db_ok = await self.db.init()
if db_ok:
states = await self.db.load_all_user_states()
loaded = self.drama_tracker.load_user_states(states)
logger.info("Loaded %d user states from database.", loaded)
await self.load_extension("cogs.sentiment")
await self.load_extension("cogs.commands")
await self.load_extension("cogs.chat")
await self.tree.sync()
logger.info("Slash commands synced.")
async def on_message(self, message: discord.Message):
logger.info(
"EVENT on_message from %s in #%s: %s",
message.author,
getattr(message.channel, "name", "DM"),
message.content[:80] if message.content else "(empty)",
)
await self.process_commands(message)
async def on_ready(self):
logger.info("Logged in as %s (ID: %d)", self.user, self.user.id)
# Set status
status_text = self.config.get("bot", {}).get(
"status", "Monitoring vibes..."
)
await self.change_presence(
activity=discord.Activity(
type=discord.ActivityType.watching, name=status_text
)
)
# Check permissions in monitored channels
monitored = self.config.get("monitoring", {}).get("channels", [])
channels = (
[self.get_channel(ch_id) for ch_id in monitored]
if monitored
else [
ch
for guild in self.guilds
for ch in guild.text_channels
]
)
for ch in channels:
if ch is None:
continue
perms = ch.permissions_for(ch.guild.me)
missing = []
if not perms.send_messages:
missing.append("Send Messages")
if not perms.add_reactions:
missing.append("Add Reactions")
if not perms.moderate_members:
missing.append("Moderate Members")
if not perms.read_messages:
missing.append("Read Messages")
if missing:
logger.warning(
"Missing permissions in #%s (%s): %s",
ch.name,
ch.guild.name,
", ".join(missing),
)
async def close(self):
await self.db.close()
await self.ollama.close()
await super().close()
def acquire_instance_lock(port: int = 39821) -> socket.socket | None:
"""Bind a TCP port as a single-instance lock. Returns the socket or None if already locked."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.bind(("127.0.0.1", port))
sock.listen(1)
return sock
except OSError:
sock.close()
return None
async def main():
# Single-instance guard — exit if another instance is already running
lock_port = int(os.getenv("BCS_LOCK_PORT", "39821"))
lock_sock = acquire_instance_lock(lock_port)
if lock_sock is None:
logger.error("Another BCS instance is already running (port %d in use). Exiting.", lock_port)
sys.exit(1)
logger.info("Instance lock acquired on port %d.", lock_port)
config = load_config()
token = os.getenv("DISCORD_BOT_TOKEN")
if not token:
logger.error("DISCORD_BOT_TOKEN not set. Check your .env file.")
sys.exit(1)
bot = BCSBot(config)
# Graceful shutdown
loop = asyncio.get_event_loop()
def _signal_handler():
logger.info("Shutdown signal received.")
asyncio.ensure_future(bot.close())
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, _signal_handler)
except NotImplementedError:
# Windows doesn't support add_signal_handler
pass
try:
async with bot:
await bot.start(token)
finally:
lock_sock.close()
if __name__ == "__main__":
asyncio.run(main())