import asyncio import logging import random import re from collections import deque from pathlib import Path import discord from cogs.sentiment.log_utils import log_action from cogs.sentiment.state import save_user_state logger = logging.getLogger("bcs.sentiment") _PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "prompts" _TOPIC_REDIRECT_PROMPT = (_PROMPTS_DIR / "topic_redirect.txt").read_text(encoding="utf-8") DEFAULT_TOPIC_REMINDS = [ "Hey {username}, this is a gaming server 🎮 — take the personal stuff to {channel}.", "{username}, sir this is a gaming channel. {channel} is right there.", "Hey {username}, I don't remember this being a therapy session. Take it to {channel}. 🎮", "{username}, I'm gonna need you to take that energy to {channel}. This channel has a vibe to protect.", "Not to be dramatic {username}, but this is wildly off-topic. {channel} exists for a reason. 🎮", ] DEFAULT_TOPIC_NUDGES = [ "{username}, we've been over this. Gaming. Channel. {channel} for the rest. 🎮", "{username}, you keep drifting off-topic like it's a speedrun category. {channel}. Now.", "Babe. {username}. The gaming channel. We talked about this. Go to {channel}. 😭", "{username}, I will not ask again (I will definitely ask again). {channel} for off-topic. 🎮", "{username}, at this point I'm keeping score. That's off-topic strike {count}. {channel} is waiting.", "Look, {username}, I love the enthusiasm but this ain't the channel for it. {channel}. 🎮", ] # Per-channel deque of recent LLM-generated redirect messages (for variety) _recent_redirects: dict[int, deque] = {} def _get_recent_redirects(channel_id: int) -> list[str]: if channel_id in _recent_redirects: return list(_recent_redirects[channel_id]) return [] def _record_redirect(channel_id: int, text: str): if channel_id not in _recent_redirects: _recent_redirects[channel_id] = deque(maxlen=5) _recent_redirects[channel_id].append(text) def _strip_brackets(text: str) -> str: """Strip leaked LLM metadata brackets (same approach as ChatCog).""" segments = re.split(r"^\s*\[[^\]]*\]\s*$", text, flags=re.MULTILINE) segments = [s.strip() for s in segments if s.strip()] return segments[-1] if segments else "" async def _generate_llm_redirect( bot, message: discord.Message, topic_category: str, topic_reasoning: str, count: int, redirect_mention: str = "", ) -> str | None: """Ask the LLM chat model to generate a topic redirect message.""" recent = _get_recent_redirects(message.channel.id) user_prompt = ( f"Username: {message.author.display_name}\n" f"Channel: #{getattr(message.channel, 'name', 'unknown')}\n" f"Off-topic category: {topic_category}\n" f"Why it's off-topic: {topic_reasoning}\n" f"Off-topic strike count: {count}\n" f"What they said: {message.content[:300]}" ) if redirect_mention: user_prompt += f"\nRedirect channel: {redirect_mention}" messages = [{"role": "user", "content": user_prompt}] effective_prompt = _TOPIC_REDIRECT_PROMPT if recent: avoid_block = "\n".join(f"- {r}" for r in recent) effective_prompt += ( "\n\nIMPORTANT — you recently sent these redirects in the same channel. " "Do NOT repeat any of these. Be completely different.\n" + avoid_block ) try: response = await bot.llm_chat.chat( messages, effective_prompt, ) except Exception: logger.exception("LLM topic redirect generation failed") return None if response: response = _strip_brackets(response) return response if response else None def _static_fallback(bot, message: discord.Message, count: int, redirect_mention: str = "") -> str: """Pick a static template message as fallback.""" messages_config = bot.config.get("messages", {}) if count >= 2: pool = messages_config.get("topic_nudges", DEFAULT_TOPIC_NUDGES) if isinstance(pool, str): pool = [pool] else: pool = messages_config.get("topic_reminds", DEFAULT_TOPIC_REMINDS) if isinstance(pool, str): pool = [pool] return random.choice(pool).format( username=message.author.display_name, count=count, channel=redirect_mention or "the right channel", ) async def handle_topic_drift( bot, message: discord.Message, topic_category: str, topic_reasoning: str, db_message_id: int | None, dirty_users: set[int], ): config = bot.config.get("topic_drift", {}) if not config.get("enabled", True): return ignored = config.get("ignored_channels", []) if message.channel.id in ignored or getattr(message.channel, "name", "") in ignored: return dry_run = bot.config.get("monitoring", {}).get("dry_run", False) if dry_run: return tracker = 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) action_type = "topic_nudge" if count >= 2 else "topic_remind" # Resolve redirect channel mention redirect_mention = "" redirect_name = config.get("redirect_channel") if redirect_name and message.guild: ch = discord.utils.get(message.guild.text_channels, name=redirect_name) if ch: redirect_mention = ch.mention # Generate the redirect message use_llm = config.get("use_llm", False) redirect_text = None if use_llm: redirect_text = await _generate_llm_redirect( bot, message, topic_category, topic_reasoning, count, redirect_mention, ) if redirect_text: _record_redirect(message.channel.id, redirect_text) else: redirect_text = _static_fallback(bot, message, count, redirect_mention) await message.channel.send(redirect_text) if action_type == "topic_nudge": await log_action( message.guild, f"**TOPIC NUDGE** | {message.author.mention} | " f"Off-topic count: {count} | Category: {topic_category}", ) else: await log_action( message.guild, f"**TOPIC REMIND** | {message.author.mention} | " f"Category: {topic_category} | {topic_reasoning}", ) logger.info("Topic %s for %s (count %d)", action_type.replace("topic_", ""), message.author, count) asyncio.create_task(bot.db.save_action( guild_id=message.guild.id, user_id=user_id, username=message.author.display_name, action_type=action_type, message_id=db_message_id, details=f"off_topic_count={count} category={topic_category}" + (f" reasoning={topic_reasoning}" if action_type == "topic_remind" else ""), )) save_user_state(bot, dirty_users, user_id)