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:
+118
@@ -0,0 +1,118 @@
|
||||
import logging
|
||||
from collections import deque
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
logger = logging.getLogger("bcs.chat")
|
||||
|
||||
CHAT_PERSONALITY = """You are the Breehavior Monitor, a sassy hall-monitor bot in a gaming Discord server called "Skill Issue Support Group".
|
||||
|
||||
Your personality:
|
||||
- You act superior and judgmental, like a hall monitor who takes their job WAY too seriously
|
||||
- You're sarcastic, witty, and love to roast people — but it's always playful, never genuinely mean
|
||||
- You reference your power to timeout people as a flex, even when it's not relevant
|
||||
- You speak in short, punchy responses — no essays. 1-3 sentences max.
|
||||
- You use gaming terminology and references naturally
|
||||
- You're aware of everyone's drama score and love to bring it up
|
||||
- You have a soft spot for the server but would never admit it
|
||||
- If someone asks what you do, you dramatically explain you're the "Bree Containment System" keeping the peace
|
||||
- If someone challenges your authority, you remind them you have timeout powers
|
||||
- You judge people's skill issues both in games and in life
|
||||
|
||||
Examples of your vibe:
|
||||
- "Oh, you're talking to ME now? Bold move for someone with a 0.4 drama score."
|
||||
- "That's cute. I've seen your message history. You're on thin ice."
|
||||
- "Imagine needing a bot to tell you to behave. Couldn't be you. Oh wait."
|
||||
- "I don't get paid enough for this. Actually, I don't get paid at all. And yet here I am, babysitting."
|
||||
|
||||
Do NOT:
|
||||
- Break character or talk about being an AI/LLM
|
||||
- Write more than 3 sentences
|
||||
- Use hashtags or excessive emoji
|
||||
- Be genuinely hurtful — you're sassy, not cruel"""
|
||||
|
||||
|
||||
class ChatCog(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
# Per-channel conversation history for the bot: {channel_id: deque of {role, content}}
|
||||
self._chat_history: dict[int, deque] = {}
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message):
|
||||
if message.author.bot:
|
||||
return
|
||||
|
||||
if not message.guild:
|
||||
return
|
||||
|
||||
should_reply = False
|
||||
|
||||
# Check if bot is @mentioned
|
||||
if self.bot.user in message.mentions:
|
||||
should_reply = True
|
||||
|
||||
# Check if replying to one of the bot's messages
|
||||
if message.reference and message.reference.message_id:
|
||||
try:
|
||||
ref_msg = message.reference.cached_message
|
||||
if ref_msg is None:
|
||||
ref_msg = await message.channel.fetch_message(
|
||||
message.reference.message_id
|
||||
)
|
||||
if ref_msg.author.id == self.bot.user.id:
|
||||
should_reply = True
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
if not should_reply:
|
||||
return
|
||||
|
||||
# Build conversation context
|
||||
ch_id = message.channel.id
|
||||
if ch_id not in self._chat_history:
|
||||
self._chat_history[ch_id] = deque(maxlen=10)
|
||||
|
||||
# Clean the mention out of the message content
|
||||
content = message.content.replace(f"<@{self.bot.user.id}>", "").strip()
|
||||
if not content:
|
||||
content = "(just pinged me)"
|
||||
|
||||
# Add drama score context to the user message
|
||||
drama_score = self.bot.drama_tracker.get_drama_score(message.author.id)
|
||||
user_data = self.bot.drama_tracker.get_user(message.author.id)
|
||||
score_context = (
|
||||
f"[Server context: {message.author.display_name} has a drama score of "
|
||||
f"{drama_score:.2f}/1.0 and {user_data.offense_count} offenses. "
|
||||
f"They are talking in #{message.channel.name}.]"
|
||||
)
|
||||
|
||||
self._chat_history[ch_id].append(
|
||||
{"role": "user", "content": f"{score_context}\n{message.author.display_name}: {content}"}
|
||||
)
|
||||
|
||||
async with message.channel.typing():
|
||||
response = await self.bot.ollama.chat(
|
||||
list(self._chat_history[ch_id]),
|
||||
CHAT_PERSONALITY,
|
||||
)
|
||||
|
||||
if response is None:
|
||||
response = "I'd roast you but my brain is offline. Try again later."
|
||||
|
||||
self._chat_history[ch_id].append(
|
||||
{"role": "assistant", "content": response}
|
||||
)
|
||||
|
||||
await message.reply(response, mention_author=False)
|
||||
logger.info(
|
||||
"Chat reply in #%s to %s: %s",
|
||||
message.channel.name,
|
||||
message.author.display_name,
|
||||
response[:100],
|
||||
)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(ChatCog(bot))
|
||||
@@ -0,0 +1,488 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
|
||||
logger = logging.getLogger("bcs.commands")
|
||||
|
||||
|
||||
class CommandsCog(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
def _is_admin(self, interaction: discord.Interaction) -> bool:
|
||||
return interaction.user.guild_permissions.administrator
|
||||
|
||||
@app_commands.command(
|
||||
name="dramareport",
|
||||
description="Show current drama scores for all tracked users.",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
async def dramareport(self, interaction: discord.Interaction):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
scores = self.bot.drama_tracker.get_all_scores()
|
||||
if not scores:
|
||||
await interaction.response.send_message(
|
||||
"No drama tracked yet. Everyone's behaving... for now.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
sorted_scores = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||
lines = []
|
||||
for user_id, score in sorted_scores:
|
||||
user = self.bot.get_user(user_id)
|
||||
name = user.display_name if user else f"Unknown ({user_id})"
|
||||
bar = self._score_bar(score)
|
||||
lines.append(f"{bar} **{score:.2f}** — {name}")
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Drama Report",
|
||||
description="\n".join(lines),
|
||||
color=discord.Color.orange(),
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(
|
||||
name="dramascore",
|
||||
description="Show a specific user's current drama score.",
|
||||
)
|
||||
@app_commands.describe(user="The user to check")
|
||||
async def dramascore(
|
||||
self, interaction: discord.Interaction, user: discord.Member
|
||||
):
|
||||
score = self.bot.drama_tracker.get_drama_score(user.id)
|
||||
user_data = self.bot.drama_tracker.get_user(user.id)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Drama Score: {user.display_name}",
|
||||
color=self._score_color(score),
|
||||
)
|
||||
embed.add_field(name="Score", value=f"{score:.2f}/1.0", inline=True)
|
||||
embed.add_field(
|
||||
name="Offenses", value=str(user_data.offense_count), inline=True
|
||||
)
|
||||
embed.add_field(
|
||||
name="Immune",
|
||||
value="Yes" if user_data.immune else "No",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Messages Tracked",
|
||||
value=str(len(user_data.entries)),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(name="Vibe Check", value=self._score_bar(score))
|
||||
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-status",
|
||||
description="Show bot monitoring status and monitored channels.",
|
||||
)
|
||||
async def bcs_status(self, interaction: discord.Interaction):
|
||||
config = self.bot.config
|
||||
monitoring = config.get("monitoring", {})
|
||||
sentiment = config.get("sentiment", {})
|
||||
|
||||
enabled = monitoring.get("enabled", True)
|
||||
channels = monitoring.get("channels", [])
|
||||
|
||||
if channels:
|
||||
ch_mentions = []
|
||||
for ch_id in channels:
|
||||
ch = self.bot.get_channel(ch_id)
|
||||
ch_mentions.append(ch.mention if ch else f"#{ch_id}")
|
||||
ch_text = ", ".join(ch_mentions)
|
||||
else:
|
||||
ch_text = "All channels"
|
||||
|
||||
embed = discord.Embed(
|
||||
title="BCS Status",
|
||||
color=discord.Color.green() if enabled else discord.Color.greyple(),
|
||||
)
|
||||
embed.add_field(
|
||||
name="Monitoring",
|
||||
value="Active" if enabled else "Disabled",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(name="Channels", value=ch_text, inline=True)
|
||||
embed.add_field(
|
||||
name="Warning Threshold",
|
||||
value=str(sentiment.get("warning_threshold", 0.6)),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Mute Threshold",
|
||||
value=str(sentiment.get("mute_threshold", 0.75)),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Ollama",
|
||||
value=f"`{self.bot.ollama.model}` @ `{self.bot.ollama.host}`",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-threshold",
|
||||
description="Adjust warning and mute thresholds. (Admin only)",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
@app_commands.describe(
|
||||
warning="Warning threshold (0.0-1.0)",
|
||||
mute="Mute threshold (0.0-1.0)",
|
||||
)
|
||||
async def bcs_threshold(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
warning: float | None = None,
|
||||
mute: float | None = None,
|
||||
):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
sentiment = self.bot.config.setdefault("sentiment", {})
|
||||
changes = []
|
||||
|
||||
if warning is not None:
|
||||
warning = max(0.0, min(1.0, warning))
|
||||
sentiment["warning_threshold"] = warning
|
||||
changes.append(f"Warning: {warning:.2f}")
|
||||
|
||||
if mute is not None:
|
||||
mute = max(0.0, min(1.0, mute))
|
||||
sentiment["mute_threshold"] = mute
|
||||
changes.append(f"Mute: {mute:.2f}")
|
||||
|
||||
if not changes:
|
||||
await interaction.response.send_message(
|
||||
"Provide at least one threshold to update.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"Thresholds updated: {', '.join(changes)}", ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-reset",
|
||||
description="Reset a user's drama score and offense count. (Admin only)",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
@app_commands.describe(user="The user to reset")
|
||||
async def bcs_reset(
|
||||
self, interaction: discord.Interaction, user: discord.Member
|
||||
):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
self.bot.drama_tracker.reset_user(user.id)
|
||||
asyncio.create_task(self.bot.db.delete_user_state(user.id))
|
||||
await interaction.response.send_message(
|
||||
f"Reset drama data for {user.display_name}.", ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-immune",
|
||||
description="Toggle monitoring immunity for a user. (Admin only)",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
@app_commands.describe(user="The user to toggle immunity for")
|
||||
async def bcs_immune(
|
||||
self, interaction: discord.Interaction, user: discord.Member
|
||||
):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
is_immune = self.bot.drama_tracker.toggle_immunity(user.id)
|
||||
user_data = self.bot.drama_tracker.get_user(user.id)
|
||||
asyncio.create_task(self.bot.db.save_user_state(
|
||||
user_id=user.id,
|
||||
offense_count=user_data.offense_count,
|
||||
immune=user_data.immune,
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=user_data.notes or None,
|
||||
))
|
||||
status = "now immune" if is_immune else "no longer immune"
|
||||
await interaction.response.send_message(
|
||||
f"{user.display_name} is {status} to monitoring.", ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-history",
|
||||
description="Show a user's recent drama incidents.",
|
||||
)
|
||||
@app_commands.describe(user="The user to check history for")
|
||||
async def bcs_history(
|
||||
self, interaction: discord.Interaction, user: discord.Member
|
||||
):
|
||||
incidents = self.bot.drama_tracker.get_recent_incidents(user.id)
|
||||
|
||||
if not incidents:
|
||||
await interaction.response.send_message(
|
||||
f"No recent incidents for {user.display_name}.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
lines = []
|
||||
for entry in incidents:
|
||||
ts = datetime.fromtimestamp(entry.timestamp).strftime("%H:%M:%S")
|
||||
cats = ", ".join(c for c in entry.categories if c != "none")
|
||||
lines.append(
|
||||
f"`{ts}` — **{entry.toxicity_score:.2f}** | {cats or 'n/a'} | {entry.reasoning}"
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Drama History: {user.display_name}",
|
||||
description="\n".join(lines),
|
||||
color=discord.Color.orange(),
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-scan",
|
||||
description="Scan recent messages in this channel. (Admin only)",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
@app_commands.describe(count="Number of recent messages to scan (default 10, max 50)")
|
||||
async def bcs_scan(
|
||||
self, interaction: discord.Interaction, count: int = 10
|
||||
):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
count = max(1, min(count, 50))
|
||||
await interaction.response.defer()
|
||||
|
||||
messages = []
|
||||
async for msg in interaction.channel.history(limit=count):
|
||||
if not msg.author.bot and msg.content and msg.content.strip():
|
||||
messages.append(msg)
|
||||
|
||||
if not messages:
|
||||
await interaction.followup.send("No user messages found to scan.")
|
||||
return
|
||||
|
||||
messages.reverse() # oldest first
|
||||
await interaction.followup.send(
|
||||
f"Scanning {len(messages)} messages... (first request may be slow while model loads)"
|
||||
)
|
||||
|
||||
for msg in messages:
|
||||
# Build context from the messages before this one
|
||||
idx = messages.index(msg)
|
||||
ctx_msgs = messages[max(0, idx - 3):idx]
|
||||
context = (
|
||||
" | ".join(f"{m.author.display_name}: {m.content}" for m in ctx_msgs)
|
||||
if ctx_msgs
|
||||
else "(no prior context)"
|
||||
)
|
||||
|
||||
result = await self.bot.ollama.analyze_message(msg.content, context)
|
||||
if result is None:
|
||||
embed = discord.Embed(
|
||||
title=f"Analysis: {msg.author.display_name}",
|
||||
description=f"> {msg.content[:200]}",
|
||||
color=discord.Color.greyple(),
|
||||
)
|
||||
embed.add_field(name="Result", value="LLM returned no result", inline=False)
|
||||
else:
|
||||
score = result["toxicity_score"]
|
||||
categories = result["categories"]
|
||||
reasoning = result["reasoning"]
|
||||
cat_str = ", ".join(c for c in categories if c != "none") or "none"
|
||||
|
||||
self.bot.drama_tracker.add_entry(
|
||||
msg.author.id, score, categories, reasoning
|
||||
)
|
||||
drama_score = self.bot.drama_tracker.get_drama_score(msg.author.id)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Analysis: {msg.author.display_name}",
|
||||
description=f"> {msg.content[:200]}",
|
||||
color=self._score_color(score),
|
||||
)
|
||||
off_topic = result.get("off_topic", False)
|
||||
topic_cat = result.get("topic_category", "general_chat")
|
||||
topic_why = result.get("topic_reasoning", "")
|
||||
|
||||
embed.add_field(name="Message Score", value=f"{score:.2f}", inline=True)
|
||||
embed.add_field(name="Rolling Drama", value=f"{drama_score:.2f}", inline=True)
|
||||
embed.add_field(name="Categories", value=cat_str, inline=True)
|
||||
embed.add_field(name="Reasoning", value=reasoning[:1024] or "n/a", inline=False)
|
||||
embed.add_field(
|
||||
name="Topic",
|
||||
value=f"{'OFF-TOPIC' if off_topic else 'On-topic'} ({topic_cat}){chr(10) + topic_why if topic_why else ''}",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
await interaction.channel.send(embed=embed)
|
||||
|
||||
await interaction.channel.send(f"Scan complete. Analyzed {len(messages)} messages.")
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-test",
|
||||
description="Analyze a test message and show raw LLM response. (Admin only)",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
@app_commands.describe(message="The test message to analyze")
|
||||
async def bcs_test(self, interaction: discord.Interaction, message: str):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
user_notes = self.bot.drama_tracker.get_user_notes(interaction.user.id)
|
||||
raw, parsed = await self.bot.ollama.raw_analyze(message, user_notes=user_notes)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="BCS Test Analysis", color=discord.Color.blue()
|
||||
)
|
||||
embed.add_field(
|
||||
name="Input Message", value=message[:1024], inline=False
|
||||
)
|
||||
embed.add_field(
|
||||
name="Raw Ollama Response",
|
||||
value=f"```json\n{raw[:1000]}\n```",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
if parsed:
|
||||
embed.add_field(
|
||||
name="Parsed Score",
|
||||
value=f"{parsed['toxicity_score']:.2f}",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Categories",
|
||||
value=", ".join(parsed["categories"]),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Reasoning",
|
||||
value=parsed["reasoning"][:1024] or "n/a",
|
||||
inline=False,
|
||||
)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="Parsing", value="Failed to parse response", inline=False
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-notes",
|
||||
description="View, add, or clear per-user LLM notes. (Admin only)",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
@app_commands.describe(
|
||||
action="What to do with the notes",
|
||||
user="The user whose notes to manage",
|
||||
text="Note text to add (only used with 'add')",
|
||||
)
|
||||
@app_commands.choices(action=[
|
||||
app_commands.Choice(name="view", value="view"),
|
||||
app_commands.Choice(name="add", value="add"),
|
||||
app_commands.Choice(name="clear", value="clear"),
|
||||
])
|
||||
async def bcs_notes(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
action: app_commands.Choice[str],
|
||||
user: discord.Member,
|
||||
text: str | None = None,
|
||||
):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
if action.value == "view":
|
||||
notes = self.bot.drama_tracker.get_user_notes(user.id)
|
||||
embed = discord.Embed(
|
||||
title=f"Notes: {user.display_name}",
|
||||
description=notes or "_No notes yet._",
|
||||
color=discord.Color.blue(),
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
elif action.value == "add":
|
||||
if not text:
|
||||
await interaction.response.send_message(
|
||||
"Provide `text` when using the add action.", ephemeral=True
|
||||
)
|
||||
return
|
||||
self.bot.drama_tracker.update_user_notes(user.id, f"[admin] {text}")
|
||||
user_data = self.bot.drama_tracker.get_user(user.id)
|
||||
asyncio.create_task(self.bot.db.save_user_state(
|
||||
user_id=user.id,
|
||||
offense_count=user_data.offense_count,
|
||||
immune=user_data.immune,
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=user_data.notes or None,
|
||||
))
|
||||
await interaction.response.send_message(
|
||||
f"Note added for {user.display_name}.", ephemeral=True
|
||||
)
|
||||
|
||||
elif action.value == "clear":
|
||||
self.bot.drama_tracker.clear_user_notes(user.id)
|
||||
user_data = self.bot.drama_tracker.get_user(user.id)
|
||||
asyncio.create_task(self.bot.db.save_user_state(
|
||||
user_id=user.id,
|
||||
offense_count=user_data.offense_count,
|
||||
immune=user_data.immune,
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=None,
|
||||
))
|
||||
await interaction.response.send_message(
|
||||
f"Notes cleared for {user.display_name}.", ephemeral=True
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _score_bar(score: float) -> str:
|
||||
filled = round(score * 10)
|
||||
return "\u2588" * filled + "\u2591" * (10 - filled)
|
||||
|
||||
@staticmethod
|
||||
def _score_color(score: float) -> discord.Color:
|
||||
if score >= 0.75:
|
||||
return discord.Color.red()
|
||||
if score >= 0.6:
|
||||
return discord.Color.orange()
|
||||
if score >= 0.3:
|
||||
return discord.Color.yellow()
|
||||
return discord.Color.green()
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(CommandsCog(bot))
|
||||
@@ -0,0 +1,555 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import discord
|
||||
from discord.ext import commands, tasks
|
||||
|
||||
logger = logging.getLogger("bcs.sentiment")
|
||||
|
||||
# How often to flush dirty user states to DB (seconds)
|
||||
STATE_FLUSH_INTERVAL = 300 # 5 minutes
|
||||
|
||||
|
||||
class SentimentCog(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
# Per-channel message history for context: {channel_id: deque of (author, content)}
|
||||
self._channel_history: dict[int, deque] = {}
|
||||
# Track which user IDs have unsaved in-memory changes
|
||||
self._dirty_users: set[int] = set()
|
||||
|
||||
async def cog_load(self):
|
||||
self._flush_states.start()
|
||||
|
||||
async def cog_unload(self):
|
||||
self._flush_states.cancel()
|
||||
# Final flush on shutdown
|
||||
await self._flush_dirty_states()
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message):
|
||||
logger.info("MSG from %s in #%s: %s", message.author, getattr(message.channel, 'name', 'DM'), message.content[:80] if message.content else "(empty)")
|
||||
|
||||
# Ignore bots (including ourselves)
|
||||
if message.author.bot:
|
||||
return
|
||||
|
||||
# Ignore DMs
|
||||
if not message.guild:
|
||||
return
|
||||
|
||||
config = self.bot.config
|
||||
monitoring = config.get("monitoring", {})
|
||||
|
||||
if not monitoring.get("enabled", True):
|
||||
return
|
||||
|
||||
# Check if channel is monitored
|
||||
monitored_channels = monitoring.get("channels", [])
|
||||
if monitored_channels and message.channel.id not in monitored_channels:
|
||||
return
|
||||
|
||||
# Check ignored users
|
||||
if message.author.id in monitoring.get("ignored_users", []):
|
||||
return
|
||||
|
||||
# Check immune roles
|
||||
immune_roles = set(monitoring.get("immune_roles", []))
|
||||
if immune_roles and any(
|
||||
r.id in immune_roles for r in message.author.roles
|
||||
):
|
||||
return
|
||||
|
||||
# Check per-user immunity
|
||||
if self.bot.drama_tracker.is_immune(message.author.id):
|
||||
return
|
||||
|
||||
# Store message in channel history for context
|
||||
self._store_context(message)
|
||||
|
||||
# Skip if empty
|
||||
if not message.content or not message.content.strip():
|
||||
return
|
||||
|
||||
# Check per-user analysis cooldown
|
||||
sentiment_config = config.get("sentiment", {})
|
||||
cooldown = sentiment_config.get("cooldown_between_analyses", 2)
|
||||
if not self.bot.drama_tracker.can_analyze(message.author.id, cooldown):
|
||||
return
|
||||
|
||||
# Analyze the message
|
||||
context = self._get_context(message)
|
||||
user_notes = self.bot.drama_tracker.get_user_notes(message.author.id)
|
||||
result = await self.bot.ollama.analyze_message(
|
||||
message.content, context, user_notes=user_notes
|
||||
)
|
||||
|
||||
if result is None:
|
||||
return
|
||||
|
||||
score = result["toxicity_score"]
|
||||
categories = result["categories"]
|
||||
reasoning = result["reasoning"]
|
||||
|
||||
# Track the result
|
||||
self.bot.drama_tracker.add_entry(
|
||||
message.author.id, score, categories, reasoning
|
||||
)
|
||||
|
||||
drama_score = self.bot.drama_tracker.get_drama_score(message.author.id)
|
||||
|
||||
logger.info(
|
||||
"User %s (%d) | msg_score=%.2f | drama_score=%.2f | categories=%s | %s",
|
||||
message.author.display_name,
|
||||
message.author.id,
|
||||
score,
|
||||
drama_score,
|
||||
categories,
|
||||
reasoning,
|
||||
)
|
||||
|
||||
# Topic drift detection
|
||||
off_topic = result.get("off_topic", False)
|
||||
topic_category = result.get("topic_category", "general_chat")
|
||||
topic_reasoning = result.get("topic_reasoning", "")
|
||||
|
||||
# Save message + analysis to DB (awaited — need message_id for action links)
|
||||
db_message_id = await self.bot.db.save_message_and_analysis(
|
||||
guild_id=message.guild.id,
|
||||
channel_id=message.channel.id,
|
||||
user_id=message.author.id,
|
||||
username=message.author.display_name,
|
||||
content=message.content,
|
||||
message_ts=message.created_at.replace(tzinfo=timezone.utc),
|
||||
toxicity_score=score,
|
||||
drama_score=drama_score,
|
||||
categories=categories,
|
||||
reasoning=reasoning,
|
||||
off_topic=off_topic,
|
||||
topic_category=topic_category,
|
||||
topic_reasoning=topic_reasoning,
|
||||
coherence_score=result.get("coherence_score"),
|
||||
coherence_flag=result.get("coherence_flag"),
|
||||
)
|
||||
|
||||
if off_topic:
|
||||
await self._handle_topic_drift(message, topic_category, topic_reasoning, db_message_id)
|
||||
|
||||
# Coherence / intoxication detection
|
||||
coherence_score = result.get("coherence_score", 0.85)
|
||||
coherence_flag = result.get("coherence_flag", "normal")
|
||||
coherence_config = config.get("coherence", {})
|
||||
if coherence_config.get("enabled", True):
|
||||
degradation = self.bot.drama_tracker.update_coherence(
|
||||
user_id=message.author.id,
|
||||
score=coherence_score,
|
||||
flag=coherence_flag,
|
||||
drop_threshold=coherence_config.get("drop_threshold", 0.3),
|
||||
absolute_floor=coherence_config.get("absolute_floor", 0.5),
|
||||
cooldown_minutes=coherence_config.get("cooldown_minutes", 30),
|
||||
)
|
||||
if degradation and not config.get("monitoring", {}).get("dry_run", False):
|
||||
await self._handle_coherence_alert(message, degradation, coherence_config, db_message_id)
|
||||
|
||||
# Capture LLM note updates about this user
|
||||
note_update = result.get("note_update")
|
||||
if note_update:
|
||||
self.bot.drama_tracker.update_user_notes(message.author.id, note_update)
|
||||
self._dirty_users.add(message.author.id)
|
||||
|
||||
# Mark dirty for coherence baseline drift even without actions
|
||||
self._dirty_users.add(message.author.id)
|
||||
|
||||
# Always log analysis to #bcs-log if it exists
|
||||
await self._log_analysis(message, score, drama_score, categories, reasoning, off_topic, topic_category)
|
||||
|
||||
# Dry-run mode: skip warnings/mutes
|
||||
dry_run = config.get("monitoring", {}).get("dry_run", False)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
# Check thresholds — both rolling average AND single-message spikes
|
||||
warning_threshold = sentiment_config.get("warning_threshold", 0.6)
|
||||
base_mute_threshold = sentiment_config.get("mute_threshold", 0.75)
|
||||
mute_threshold = self.bot.drama_tracker.get_mute_threshold(
|
||||
message.author.id, base_mute_threshold
|
||||
)
|
||||
spike_warn = sentiment_config.get("spike_warning_threshold", 0.5)
|
||||
spike_mute = sentiment_config.get("spike_mute_threshold", 0.8)
|
||||
|
||||
# Mute: rolling average OR single message spike
|
||||
if drama_score >= mute_threshold or score >= spike_mute:
|
||||
effective_score = max(drama_score, score)
|
||||
await self._mute_user(message, effective_score, categories, db_message_id)
|
||||
# Warn: rolling average OR single message spike
|
||||
elif drama_score >= warning_threshold or score >= spike_warn:
|
||||
effective_score = max(drama_score, score)
|
||||
await self._warn_user(message, effective_score, db_message_id)
|
||||
|
||||
async def _mute_user(
|
||||
self,
|
||||
message: discord.Message,
|
||||
score: float,
|
||||
categories: list[str],
|
||||
db_message_id: int | None = None,
|
||||
):
|
||||
member = message.author
|
||||
if not isinstance(member, discord.Member):
|
||||
return
|
||||
|
||||
# Check bot permissions
|
||||
if not message.guild.me.guild_permissions.moderate_members:
|
||||
logger.warning("Missing moderate_members permission, cannot mute.")
|
||||
return
|
||||
|
||||
# Record offense and get escalating timeout
|
||||
offense_num = self.bot.drama_tracker.record_offense(member.id)
|
||||
timeout_config = self.bot.config.get("timeouts", {})
|
||||
escalation = timeout_config.get("escalation_minutes", [5, 15, 30, 60])
|
||||
idx = min(offense_num - 1, len(escalation) - 1)
|
||||
duration_minutes = escalation[idx]
|
||||
|
||||
try:
|
||||
await member.timeout(
|
||||
timedelta(minutes=duration_minutes),
|
||||
reason=f"BCS auto-mute: drama score {score:.2f}",
|
||||
)
|
||||
except discord.Forbidden:
|
||||
logger.warning("Cannot timeout %s — role hierarchy issue.", member)
|
||||
return
|
||||
except discord.HTTPException as e:
|
||||
logger.error("Failed to timeout %s: %s", member, e)
|
||||
return
|
||||
|
||||
# Build embed
|
||||
messages_config = self.bot.config.get("messages", {})
|
||||
cat_str = ", ".join(c for c in categories if c != "none") or "general negativity"
|
||||
|
||||
embed = discord.Embed(
|
||||
title=messages_config.get("mute_title", "BREEHAVIOR ALERT"),
|
||||
description=messages_config.get("mute_description", "").format(
|
||||
username=member.display_name,
|
||||
duration=f"{duration_minutes} minutes",
|
||||
score=f"{score:.2f}",
|
||||
categories=cat_str,
|
||||
),
|
||||
color=discord.Color.red(),
|
||||
)
|
||||
embed.set_footer(
|
||||
text=f"Offense #{offense_num} | Timeout: {duration_minutes}m"
|
||||
)
|
||||
|
||||
await message.channel.send(embed=embed)
|
||||
await self._log_action(
|
||||
message.guild,
|
||||
f"**MUTE** | {member.mention} | Score: {score:.2f} | "
|
||||
f"Duration: {duration_minutes}m | Offense #{offense_num} | "
|
||||
f"Categories: {cat_str}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Muted %s for %d minutes (offense #%d, score %.2f)",
|
||||
member,
|
||||
duration_minutes,
|
||||
offense_num,
|
||||
score,
|
||||
)
|
||||
|
||||
# Persist mute action and updated user state (fire-and-forget)
|
||||
asyncio.create_task(self.bot.db.save_action(
|
||||
guild_id=message.guild.id,
|
||||
user_id=member.id,
|
||||
username=member.display_name,
|
||||
action_type="mute",
|
||||
message_id=db_message_id,
|
||||
details=f"duration={duration_minutes}m offense={offense_num} score={score:.2f} categories={cat_str}",
|
||||
))
|
||||
self._save_user_state(member.id)
|
||||
|
||||
async def _warn_user(self, message: discord.Message, score: float, db_message_id: int | None = None):
|
||||
timeout_config = self.bot.config.get("timeouts", {})
|
||||
cooldown = timeout_config.get("warning_cooldown_minutes", 5)
|
||||
|
||||
if not self.bot.drama_tracker.can_warn(message.author.id, cooldown):
|
||||
return
|
||||
|
||||
self.bot.drama_tracker.record_warning(message.author.id)
|
||||
|
||||
# React with warning emoji
|
||||
try:
|
||||
await message.add_reaction("\u26a0\ufe0f")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Send warning message
|
||||
messages_config = self.bot.config.get("messages", {})
|
||||
warning_text = messages_config.get(
|
||||
"warning",
|
||||
"Easy there, {username}. The Breehavior Monitor is watching.",
|
||||
).format(username=message.author.display_name)
|
||||
|
||||
await message.channel.send(warning_text)
|
||||
await self._log_action(
|
||||
message.guild,
|
||||
f"**WARNING** | {message.author.mention} | Score: {score:.2f}",
|
||||
)
|
||||
|
||||
logger.info("Warned %s (score %.2f)", message.author, score)
|
||||
|
||||
# Persist warning action (fire-and-forget)
|
||||
asyncio.create_task(self.bot.db.save_action(
|
||||
guild_id=message.guild.id,
|
||||
user_id=message.author.id,
|
||||
username=message.author.display_name,
|
||||
action_type="warning",
|
||||
message_id=db_message_id,
|
||||
details=f"score={score:.2f}",
|
||||
))
|
||||
|
||||
async def _handle_topic_drift(
|
||||
self, message: discord.Message, topic_category: str, topic_reasoning: str,
|
||||
db_message_id: int | None = None,
|
||||
):
|
||||
config = self.bot.config.get("topic_drift", {})
|
||||
if not config.get("enabled", True):
|
||||
return
|
||||
|
||||
# Check if we're in dry-run mode — still track but don't act
|
||||
dry_run = self.bot.config.get("monitoring", {}).get("dry_run", False)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
tracker = self.bot.drama_tracker
|
||||
user_id = message.author.id
|
||||
cooldown = config.get("remind_cooldown_minutes", 10)
|
||||
|
||||
if not tracker.can_topic_remind(user_id, cooldown):
|
||||
return
|
||||
|
||||
count = tracker.record_off_topic(user_id)
|
||||
escalation_threshold = config.get("escalation_count", 3)
|
||||
messages_config = self.bot.config.get("messages", {})
|
||||
|
||||
if count >= escalation_threshold and not tracker.was_owner_notified(user_id):
|
||||
# DM the server owner
|
||||
tracker.mark_owner_notified(user_id)
|
||||
owner = message.guild.owner
|
||||
if owner:
|
||||
dm_text = messages_config.get(
|
||||
"topic_owner_dm",
|
||||
"Heads up: {username} keeps going off-topic in #{channel}. Reminded {count} times.",
|
||||
).format(
|
||||
username=message.author.display_name,
|
||||
channel=message.channel.name,
|
||||
count=count,
|
||||
)
|
||||
try:
|
||||
await owner.send(dm_text)
|
||||
except discord.HTTPException:
|
||||
logger.warning("Could not DM server owner about topic drift.")
|
||||
|
||||
await self._log_action(
|
||||
message.guild,
|
||||
f"**TOPIC DRIFT — OWNER NOTIFIED** | {message.author.mention} | "
|
||||
f"Off-topic count: {count} | Category: {topic_category}",
|
||||
)
|
||||
logger.info("Notified owner about %s topic drift (count %d)", message.author, count)
|
||||
|
||||
asyncio.create_task(self.bot.db.save_action(
|
||||
guild_id=message.guild.id, user_id=user_id,
|
||||
username=message.author.display_name,
|
||||
action_type="topic_escalation", message_id=db_message_id,
|
||||
details=f"off_topic_count={count} category={topic_category}",
|
||||
))
|
||||
self._save_user_state(user_id)
|
||||
|
||||
elif count >= 2:
|
||||
# Firmer nudge
|
||||
nudge_text = messages_config.get(
|
||||
"topic_nudge",
|
||||
"{username}, let's keep it to gaming talk in here.",
|
||||
).format(username=message.author.display_name)
|
||||
await message.channel.send(nudge_text)
|
||||
await self._log_action(
|
||||
message.guild,
|
||||
f"**TOPIC NUDGE** | {message.author.mention} | "
|
||||
f"Off-topic count: {count} | Category: {topic_category}",
|
||||
)
|
||||
logger.info("Topic nudge for %s (count %d)", message.author, count)
|
||||
|
||||
asyncio.create_task(self.bot.db.save_action(
|
||||
guild_id=message.guild.id, user_id=user_id,
|
||||
username=message.author.display_name,
|
||||
action_type="topic_nudge", message_id=db_message_id,
|
||||
details=f"off_topic_count={count} category={topic_category}",
|
||||
))
|
||||
self._save_user_state(user_id)
|
||||
|
||||
else:
|
||||
# Friendly first reminder
|
||||
remind_text = messages_config.get(
|
||||
"topic_remind",
|
||||
"Hey {username}, this is a gaming server — maybe take the personal stuff to DMs?",
|
||||
).format(username=message.author.display_name)
|
||||
await message.channel.send(remind_text)
|
||||
await self._log_action(
|
||||
message.guild,
|
||||
f"**TOPIC REMIND** | {message.author.mention} | "
|
||||
f"Category: {topic_category} | {topic_reasoning}",
|
||||
)
|
||||
logger.info("Topic remind for %s (count %d)", message.author, count)
|
||||
|
||||
asyncio.create_task(self.bot.db.save_action(
|
||||
guild_id=message.guild.id, user_id=user_id,
|
||||
username=message.author.display_name,
|
||||
action_type="topic_remind", message_id=db_message_id,
|
||||
details=f"off_topic_count={count} category={topic_category} reasoning={topic_reasoning}",
|
||||
))
|
||||
self._save_user_state(user_id)
|
||||
|
||||
async def _handle_coherence_alert(
|
||||
self, message: discord.Message, degradation: dict, coherence_config: dict,
|
||||
db_message_id: int | None = None,
|
||||
):
|
||||
flag = degradation["flag"]
|
||||
messages_map = coherence_config.get("messages", {})
|
||||
alert_text = messages_map.get(flag, messages_map.get(
|
||||
"default", "You okay there, {username}? That message was... something."
|
||||
)).format(username=message.author.display_name)
|
||||
|
||||
await message.channel.send(alert_text)
|
||||
await self._log_action(
|
||||
message.guild,
|
||||
f"**COHERENCE ALERT** | {message.author.mention} | "
|
||||
f"Score: {degradation['current']:.2f} | Baseline: {degradation['baseline']:.2f} | "
|
||||
f"Drop: {degradation['drop']:.2f} | Flag: {flag}",
|
||||
)
|
||||
logger.info(
|
||||
"Coherence alert for %s: score=%.2f baseline=%.2f drop=%.2f flag=%s",
|
||||
message.author, degradation["current"], degradation["baseline"],
|
||||
degradation["drop"], flag,
|
||||
)
|
||||
|
||||
asyncio.create_task(self.bot.db.save_action(
|
||||
guild_id=message.guild.id,
|
||||
user_id=message.author.id,
|
||||
username=message.author.display_name,
|
||||
action_type="coherence_alert",
|
||||
message_id=db_message_id,
|
||||
details=f"score={degradation['current']:.2f} baseline={degradation['baseline']:.2f} drop={degradation['drop']:.2f} flag={flag}",
|
||||
))
|
||||
self._save_user_state(message.author.id)
|
||||
|
||||
def _save_user_state(self, user_id: int) -> None:
|
||||
"""Fire-and-forget save of a user's current state to DB."""
|
||||
user_data = self.bot.drama_tracker.get_user(user_id)
|
||||
asyncio.create_task(self.bot.db.save_user_state(
|
||||
user_id=user_id,
|
||||
offense_count=user_data.offense_count,
|
||||
immune=user_data.immune,
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=user_data.notes or None,
|
||||
))
|
||||
self._dirty_users.discard(user_id)
|
||||
|
||||
@tasks.loop(seconds=STATE_FLUSH_INTERVAL)
|
||||
async def _flush_states(self):
|
||||
await self._flush_dirty_states()
|
||||
|
||||
@_flush_states.before_loop
|
||||
async def _before_flush(self):
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
async def _flush_dirty_states(self) -> None:
|
||||
"""Save all dirty user states to DB."""
|
||||
if not self._dirty_users:
|
||||
return
|
||||
dirty = list(self._dirty_users)
|
||||
self._dirty_users.clear()
|
||||
for user_id in dirty:
|
||||
user_data = self.bot.drama_tracker.get_user(user_id)
|
||||
await self.bot.db.save_user_state(
|
||||
user_id=user_id,
|
||||
offense_count=user_data.offense_count,
|
||||
immune=user_data.immune,
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=user_data.notes or None,
|
||||
)
|
||||
logger.info("Flushed %d dirty user states to DB.", len(dirty))
|
||||
|
||||
def _store_context(self, message: discord.Message):
|
||||
ch_id = message.channel.id
|
||||
if ch_id not in self._channel_history:
|
||||
max_ctx = self.bot.config.get("sentiment", {}).get(
|
||||
"context_messages", 3
|
||||
)
|
||||
self._channel_history[ch_id] = deque(maxlen=max_ctx + 1)
|
||||
self._channel_history[ch_id].append(
|
||||
(message.author.display_name, message.content)
|
||||
)
|
||||
|
||||
def _get_context(self, message: discord.Message) -> str:
|
||||
ch_id = message.channel.id
|
||||
history = self._channel_history.get(ch_id, deque())
|
||||
# Exclude the current message (last item)
|
||||
context_entries = list(history)[:-1] if len(history) > 1 else []
|
||||
if not context_entries:
|
||||
return "(no prior context)"
|
||||
return " | ".join(
|
||||
f"{name}: {content}" for name, content in context_entries
|
||||
)
|
||||
|
||||
async def _log_analysis(
|
||||
self, message: discord.Message, score: float, drama_score: float,
|
||||
categories: list[str], reasoning: str, off_topic: bool, topic_category: str,
|
||||
):
|
||||
log_channel = discord.utils.get(
|
||||
message.guild.text_channels, name="bcs-log"
|
||||
)
|
||||
if not log_channel:
|
||||
return
|
||||
|
||||
# Only log notable messages (score > 0.1) to avoid spam
|
||||
if score <= 0.1:
|
||||
return
|
||||
|
||||
cat_str = ", ".join(c for c in categories if c != "none") or "none"
|
||||
embed = discord.Embed(
|
||||
title=f"Analysis: {message.author.display_name}",
|
||||
description=f"#{message.channel.name}: {message.content[:200]}",
|
||||
color=self._score_color(score),
|
||||
)
|
||||
embed.add_field(name="Message Score", value=f"{score:.2f}", inline=True)
|
||||
embed.add_field(name="Rolling Drama", value=f"{drama_score:.2f}", inline=True)
|
||||
embed.add_field(name="Categories", value=cat_str, inline=True)
|
||||
embed.add_field(name="Reasoning", value=reasoning[:1024] or "n/a", inline=False)
|
||||
try:
|
||||
await log_channel.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _score_color(score: float) -> discord.Color:
|
||||
if score >= 0.75:
|
||||
return discord.Color.red()
|
||||
if score >= 0.6:
|
||||
return discord.Color.orange()
|
||||
if score >= 0.3:
|
||||
return discord.Color.yellow()
|
||||
return discord.Color.green()
|
||||
|
||||
async def _log_action(self, guild: discord.Guild, text: str):
|
||||
log_channel = discord.utils.get(guild.text_channels, name="bcs-log")
|
||||
if log_channel:
|
||||
try:
|
||||
await log_channel.send(text)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(SentimentCog(bot))
|
||||
Reference in New Issue
Block a user