1. Broader regex to strip leaked metadata even when the LLM drops the "Server context:" prefix but keeps the content. 2. Skip sentiment analysis for messages that mention or reply to the bot. Users interacting with the bot in roast/chat modes shouldn't have those messages inflate their drama score. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
846 lines
33 KiB
Python
846 lines
33 KiB
Python
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()
|
|
# Per-user redirect cooldown: {user_id: last_redirect_datetime}
|
|
self._redirect_cooldowns: dict[int, datetime] = {}
|
|
# Debounce buffer: keyed by (channel_id, user_id), stores list of messages
|
|
self._message_buffer: dict[tuple[int, int], list[discord.Message]] = {}
|
|
# Pending debounce timer tasks
|
|
self._debounce_tasks: dict[tuple[int, int], asyncio.Task] = {}
|
|
# Per-channel poll cooldown: {channel_id: last_poll_datetime}
|
|
self._poll_cooldowns: dict[int, datetime] = {}
|
|
|
|
async def cog_load(self):
|
|
self._flush_states.start()
|
|
|
|
async def cog_unload(self):
|
|
self._flush_states.cancel()
|
|
# Cancel all pending debounce timers and process remaining buffers
|
|
for task in self._debounce_tasks.values():
|
|
task.cancel()
|
|
self._debounce_tasks.clear()
|
|
for key in list(self._message_buffer):
|
|
await self._process_buffered(key)
|
|
# 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
|
|
|
|
# Skip sentiment analysis for messages directed at the bot
|
|
# (mentions, replies to bot) — users interacting with the bot
|
|
# in roast/chat modes shouldn't have those messages scored as toxic
|
|
directed_at_bot = self.bot.user in message.mentions
|
|
if not directed_at_bot and message.reference and message.reference.message_id:
|
|
ref = message.reference.cached_message
|
|
if ref and ref.author.id == self.bot.user.id:
|
|
directed_at_bot = True
|
|
if directed_at_bot:
|
|
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
|
|
|
|
# Buffer the message and start/reset debounce timer
|
|
key = (message.channel.id, message.author.id)
|
|
if key not in self._message_buffer:
|
|
self._message_buffer[key] = []
|
|
self._message_buffer[key].append(message)
|
|
|
|
# Cancel existing debounce timer for this user+channel
|
|
existing_task = self._debounce_tasks.get(key)
|
|
if existing_task and not existing_task.done():
|
|
existing_task.cancel()
|
|
|
|
# Skip debounce when bot is @mentioned so warnings fire before chat replies
|
|
if self.bot.user in message.mentions:
|
|
batch_window = 0
|
|
else:
|
|
batch_window = config.get("sentiment", {}).get("batch_window_seconds", 3)
|
|
|
|
self._debounce_tasks[key] = asyncio.create_task(
|
|
self._debounce_then_process(key, batch_window)
|
|
)
|
|
|
|
async def _debounce_then_process(self, key: tuple[int, int], delay: float):
|
|
"""Sleep for the debounce window, then process the buffered messages."""
|
|
try:
|
|
await asyncio.sleep(delay)
|
|
await self._process_buffered(key)
|
|
except asyncio.CancelledError:
|
|
pass # Timer was reset by a new message — expected
|
|
|
|
async def _process_buffered(self, key: tuple[int, int]):
|
|
"""Combine buffered messages and run the analysis pipeline once."""
|
|
messages = self._message_buffer.pop(key, [])
|
|
self._debounce_tasks.pop(key, None)
|
|
|
|
if not messages:
|
|
return
|
|
|
|
# Use the last message as the reference for channel, author, guild, etc.
|
|
message = messages[-1]
|
|
combined_content = "\n".join(m.content for m in messages if m.content and m.content.strip())
|
|
|
|
if not combined_content.strip():
|
|
return
|
|
|
|
batch_count = len(messages)
|
|
if batch_count > 1:
|
|
logger.info(
|
|
"Batched %d messages from %s in #%s",
|
|
batch_count, message.author.display_name,
|
|
getattr(message.channel, 'name', 'unknown'),
|
|
)
|
|
|
|
config = self.bot.config
|
|
monitoring = config.get("monitoring", {})
|
|
sentiment_config = config.get("sentiment", {})
|
|
|
|
# Build channel context for game detection
|
|
game_channels = config.get("game_channels", {})
|
|
channel_context = self._build_channel_context(message, game_channels)
|
|
|
|
# Analyze the combined message (triage with lightweight model)
|
|
context = self._get_context(message)
|
|
user_notes = self.bot.drama_tracker.get_user_notes(message.author.id)
|
|
result = await self.bot.llm.analyze_message(
|
|
combined_content, context, user_notes=user_notes,
|
|
channel_context=channel_context,
|
|
)
|
|
|
|
if result is None:
|
|
return
|
|
|
|
# Escalation: re-analyze with heavy model if triage flags something
|
|
escalation_threshold = sentiment_config.get("escalation_threshold", 0.25)
|
|
needs_escalation = (
|
|
result["toxicity_score"] >= escalation_threshold
|
|
or result.get("off_topic", False)
|
|
or result.get("coherence_score", 1.0) < 0.6
|
|
)
|
|
if needs_escalation:
|
|
triage_score = result["toxicity_score"]
|
|
heavy_result = await self.bot.llm_heavy.analyze_message(
|
|
combined_content, context, user_notes=user_notes,
|
|
channel_context=channel_context,
|
|
)
|
|
if heavy_result is not None:
|
|
logger.info(
|
|
"Escalated to heavy model (triage_score=%.2f) for %s",
|
|
triage_score, message.author.display_name,
|
|
)
|
|
result = heavy_result
|
|
|
|
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=combined_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)
|
|
|
|
# Game channel redirect detection
|
|
detected_game = result.get("detected_game")
|
|
if detected_game and game_channels and not monitoring.get("dry_run", False):
|
|
await self._handle_channel_redirect(message, detected_game, game_channels, 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)
|
|
|
|
# Disagreement poll detection
|
|
polls_config = config.get("polls", {})
|
|
if (
|
|
polls_config.get("enabled", False)
|
|
and result.get("disagreement_detected", False)
|
|
and result.get("disagreement_summary")
|
|
and not monitoring.get("dry_run", False)
|
|
):
|
|
await self._handle_disagreement_poll(message, result["disagreement_summary"], polls_config)
|
|
|
|
# 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 — use relaxed thresholds if the active mode says so
|
|
mode_config = self.bot.get_mode_config()
|
|
moderation_level = mode_config.get("moderation", "full")
|
|
if moderation_level == "relaxed" and "relaxed_thresholds" in mode_config:
|
|
rt = mode_config["relaxed_thresholds"]
|
|
warning_threshold = rt.get("warning_threshold", 0.80)
|
|
base_mute_threshold = rt.get("mute_threshold", 0.85)
|
|
spike_warn = rt.get("spike_warning_threshold", 0.70)
|
|
spike_mute = rt.get("spike_mute_threshold", 0.85)
|
|
else:
|
|
warning_threshold = sentiment_config.get("warning_threshold", 0.6)
|
|
base_mute_threshold = sentiment_config.get("mute_threshold", 0.75)
|
|
spike_warn = sentiment_config.get("spike_warning_threshold", 0.5)
|
|
spike_mute = sentiment_config.get("spike_mute_threshold", 0.8)
|
|
mute_threshold = self.bot.drama_tracker.get_mute_threshold(
|
|
message.author.id, base_mute_threshold
|
|
)
|
|
|
|
# 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)
|
|
|
|
async def _handle_disagreement_poll(
|
|
self, message: discord.Message, summary: dict, polls_config: dict,
|
|
):
|
|
"""Create a Discord poll to settle a detected disagreement."""
|
|
ch_id = message.channel.id
|
|
cooldown_minutes = polls_config.get("cooldown_minutes", 60)
|
|
now = datetime.now(timezone.utc)
|
|
|
|
# Check per-channel cooldown
|
|
last_poll = self._poll_cooldowns.get(ch_id)
|
|
if last_poll and (now - last_poll) < timedelta(minutes=cooldown_minutes):
|
|
return
|
|
|
|
topic = summary.get("topic", "Who's right?")
|
|
side_a = summary.get("side_a", "Side A")
|
|
side_b = summary.get("side_b", "Side B")
|
|
user_a = summary.get("user_a", "")
|
|
user_b = summary.get("user_b", "")
|
|
|
|
# Build poll question
|
|
question_text = f"Settle this: {topic}"[:300]
|
|
|
|
# Build answer labels with usernames
|
|
label_a = f"{side_a} ({user_a})" if user_a else side_a
|
|
label_b = f"{side_b} ({user_b})" if user_b else side_b
|
|
|
|
duration_hours = polls_config.get("duration_hours", 4)
|
|
|
|
try:
|
|
poll = discord.Poll(
|
|
question=question_text,
|
|
duration=timedelta(hours=duration_hours),
|
|
)
|
|
poll.add_answer(text=label_a[:55])
|
|
poll.add_answer(text=label_b[:55])
|
|
|
|
await message.channel.send(poll=poll)
|
|
self._poll_cooldowns[ch_id] = now
|
|
|
|
await self._log_action(
|
|
message.guild,
|
|
f"**AUTO-POLL** | #{message.channel.name} | "
|
|
f"{question_text} | {label_a} vs {label_b}",
|
|
)
|
|
logger.info(
|
|
"Auto-poll created in #%s: %s | %s vs %s",
|
|
message.channel.name, topic, label_a, label_b,
|
|
)
|
|
except discord.HTTPException as e:
|
|
logger.error("Failed to create disagreement poll: %s", e)
|
|
|
|
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 _build_channel_context(self, message: discord.Message, game_channels: dict) -> str:
|
|
"""Build a channel context string for LLM game detection."""
|
|
if not game_channels:
|
|
return ""
|
|
channel_name = getattr(message.channel, "name", "")
|
|
current_game = game_channels.get(channel_name)
|
|
lines = []
|
|
if current_game:
|
|
lines.append(f"Current channel: #{channel_name} ({current_game})")
|
|
else:
|
|
lines.append(f"Current channel: #{channel_name}")
|
|
channel_list = ", ".join(f"#{ch} ({game})" for ch, game in game_channels.items())
|
|
lines.append(f"Game channels: {channel_list}")
|
|
return "\n".join(lines)
|
|
|
|
async def _handle_channel_redirect(
|
|
self, message: discord.Message, detected_game: str,
|
|
game_channels: dict, db_message_id: int | None = None,
|
|
):
|
|
"""Send a redirect message if the user is talking about a different game."""
|
|
channel_name = getattr(message.channel, "name", "")
|
|
|
|
# Only redirect if message is in a game channel
|
|
if channel_name not in game_channels:
|
|
return
|
|
|
|
# No redirect needed if detected game matches current channel
|
|
if detected_game == channel_name:
|
|
return
|
|
|
|
# Detected game must be a valid game channel
|
|
if detected_game not in game_channels:
|
|
return
|
|
|
|
# Find the target channel in the guild
|
|
target_channel = discord.utils.get(
|
|
message.guild.text_channels, name=detected_game
|
|
)
|
|
if not target_channel:
|
|
return
|
|
|
|
# Check per-user cooldown (reuse topic_drift remind_cooldown_minutes)
|
|
user_id = message.author.id
|
|
cooldown_minutes = self.bot.config.get("topic_drift", {}).get("remind_cooldown_minutes", 10)
|
|
now = datetime.now(timezone.utc)
|
|
last_redirect = self._redirect_cooldowns.get(user_id)
|
|
if last_redirect and (now - last_redirect) < timedelta(minutes=cooldown_minutes):
|
|
return
|
|
|
|
self._redirect_cooldowns[user_id] = now
|
|
|
|
# Send redirect message
|
|
messages_config = self.bot.config.get("messages", {})
|
|
game_name = game_channels[detected_game]
|
|
redirect_text = messages_config.get(
|
|
"channel_redirect",
|
|
"Hey {username}, that sounds like {game} talk — head over to {channel} for that!",
|
|
).format(
|
|
username=message.author.display_name,
|
|
game=game_name,
|
|
channel=target_channel.mention,
|
|
)
|
|
|
|
await message.channel.send(redirect_text)
|
|
|
|
await self._log_action(
|
|
message.guild,
|
|
f"**CHANNEL REDIRECT** | {message.author.mention} | "
|
|
f"#{channel_name} → #{detected_game} ({game_name})",
|
|
)
|
|
logger.info(
|
|
"Redirected %s from #%s to #%s (%s)",
|
|
message.author, channel_name, detected_game, game_name,
|
|
)
|
|
|
|
asyncio.create_task(self.bot.db.save_action(
|
|
guild_id=message.guild.id,
|
|
user_id=user_id,
|
|
username=message.author.display_name,
|
|
action_type="channel_redirect",
|
|
message_id=db_message_id,
|
|
details=f"from=#{channel_name} to=#{detected_game} game={game_name}",
|
|
))
|
|
|
|
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", 8
|
|
)
|
|
self._channel_history[ch_id] = deque(maxlen=max_ctx)
|
|
self._channel_history[ch_id].append(
|
|
(message.author.display_name, message.content, datetime.now(timezone.utc))
|
|
)
|
|
|
|
def _get_context(self, message: discord.Message) -> str:
|
|
"""Build a timestamped chat log from recent channel messages.
|
|
|
|
Excludes messages currently buffered for this user+channel
|
|
(those appear in the TARGET MESSAGE section instead).
|
|
"""
|
|
ch_id = message.channel.id
|
|
history = self._channel_history.get(ch_id, deque())
|
|
if not history:
|
|
return "(no prior context)"
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
# Collect IDs of messages in the current debounce batch so we can skip them
|
|
batch_key = (ch_id, message.author.id)
|
|
batch_msgs = self._message_buffer.get(batch_key, [])
|
|
# Build a set of (author, content) from the batch for fast lookup
|
|
batch_set = {(m.author.display_name, m.content) for m in batch_msgs}
|
|
|
|
lines = []
|
|
for name, content, ts in history:
|
|
if (name, content) in batch_set:
|
|
continue
|
|
delta = now - ts
|
|
rel = self._format_relative_time(delta)
|
|
lines.append(f"[{rel}] {name}: {content}")
|
|
|
|
if not lines:
|
|
return "(no prior context)"
|
|
return "\n".join(lines)
|
|
|
|
@staticmethod
|
|
def _format_relative_time(delta: timedelta) -> str:
|
|
total_seconds = int(delta.total_seconds())
|
|
if total_seconds < 60:
|
|
return f"~{total_seconds}s ago"
|
|
minutes = total_seconds // 60
|
|
if minutes < 60:
|
|
return f"~{minutes}m ago"
|
|
hours = minutes // 60
|
|
return f"~{hours}h ago"
|
|
|
|
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))
|