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:
213
bot.py
Normal file
213
bot.py
Normal 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())
|
||||
Reference in New Issue
Block a user