Files
Breehavior-Monitor/bot.py
AJ Isaacs 28fb66d5f9 Switch LLM backend from llama.cpp/Qwen to OpenAI
- Default models: gpt-4o-mini (triage), gpt-4o (escalation)
- Remove Qwen-specific /no_think hacks
- Reduce timeout from 600s to 120s, increase concurrency semaphore to 4
- Support empty LLM_BASE_URL to use OpenAI directly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:07:53 -05:00

248 lines
8.1 KiB
Python

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.llm_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
# Database (initialized async in setup_hook)
self.db = Database()
# LLM clients (OpenAI — set LLM_BASE_URL to override for local models)
llm_base_url = os.getenv("LLM_BASE_URL", "")
llm_model = os.getenv("LLM_MODEL", "gpt-4o-mini")
llm_api_key = os.getenv("LLM_API_KEY", "")
self.llm = LLMClient(llm_base_url, llm_model, llm_api_key, db=self.db)
# Heavy/escalation model for re-analysis, chat, and manual commands
llm_heavy_model = os.getenv("LLM_ESCALATION_MODEL", "gpt-4o")
self.llm_heavy = LLMClient(llm_base_url, llm_heavy_model, llm_api_key, db=self.db)
# Active mode (server-wide)
modes_config = config.get("modes", {})
self.current_mode = modes_config.get("default_mode", "default")
# 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),
)
def get_mode_config(self) -> dict:
"""Return the config dict for the currently active mode."""
modes = self.config.get("modes", {})
return modes.get(self.current_mode, modes.get("default", {}))
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)
# Restore saved mode
saved_mode = await self.db.load_setting("current_mode")
if saved_mode:
modes = self.config.get("modes", {})
if saved_mode in modes and isinstance(modes.get(saved_mode), dict):
self.current_mode = saved_mode
logger.info("Restored saved mode: %s", saved_mode)
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.")
# Quick connectivity check
try:
await self.llm._client.chat.completions.create(
model=self.llm.model,
messages=[{"role": "user", "content": "hi"}],
max_tokens=1,
)
logger.info("LLM connectivity check passed.")
except Exception as e:
logger.warning("LLM connectivity check failed: %s", e)
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 based on active mode
mode_config = self.get_mode_config()
status_text = mode_config.get("description") or 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.llm.close()
await self.llm_heavy.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())