diff --git a/cogs/sentiment/__init__.py b/cogs/sentiment/__init__.py index 48cb030..558068e 100644 --- a/cogs/sentiment/__init__.py +++ b/cogs/sentiment/__init__.py @@ -13,6 +13,7 @@ from cogs.sentiment.coherence import handle_coherence_alert from cogs.sentiment.log_utils import log_analysis from cogs.sentiment.state import flush_dirty_states from cogs.sentiment.topic_drift import handle_topic_drift +from cogs.sentiment.unblock_nag import handle_unblock_nag, matches_unblock_nag logger = logging.getLogger("bcs.sentiment") @@ -153,6 +154,12 @@ class SentimentCog(commands.Cog): if not message.content or not message.content.strip(): return + # Check for unblock nagging (keyword-based, no LLM needed for detection) + if matches_unblock_nag(message.content): + asyncio.create_task(handle_unblock_nag( + self.bot, message, self._dirty_users, + )) + # Buffer the message and start/reset debounce timer (per-channel) channel_id = message.channel.id if channel_id not in self._message_buffer: diff --git a/cogs/sentiment/unblock_nag.py b/cogs/sentiment/unblock_nag.py new file mode 100644 index 0000000..c3319b9 --- /dev/null +++ b/cogs/sentiment/unblock_nag.py @@ -0,0 +1,161 @@ +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" +_UNBLOCK_REDIRECT_PROMPT = (_PROMPTS_DIR / "unblock_redirect.txt").read_text(encoding="utf-8") + +# Regex: matches "unblock" as a whole word, case-insensitive +UNBLOCK_PATTERN = re.compile(r"\bunblock(?:ed|ing|s)?\b", re.IGNORECASE) + +DEFAULT_UNBLOCK_REMINDS = [ + "{username}, begging to be unblocked in chat is not the move. Take it up with an admin. 🙄", + "{username}, nobody's getting unblocked because you asked nicely in a gaming channel.", + "Hey {username}, the unblock button isn't in this chat. Just saying.", + "{username}, I admire the persistence but this isn't the unblock hotline.", + "{username}, that's between you and whoever blocked you. Chat isn't the appeals court.", +] + +DEFAULT_UNBLOCK_NUDGES = [ + "{username}, we've been over this. No amount of asking here is going to change anything. 🙄", + "{username}, I'm starting to think you enjoy being told no. Still not getting unblocked via chat.", + "{username}, at this point I could set a reminder for your next unblock request. Take it to an admin.", + "Babe. {username}. We've had this conversation {count} times. It's not happening here. 😭", + "{username}, I'm keeping a tally and you're at {count}. The answer is still the same.", +] + +# Per-channel deque of recent LLM-generated 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.""" + 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 "" + + +def matches_unblock_nag(content: str) -> bool: + """Check if a message contains unblock-related nagging.""" + return bool(UNBLOCK_PATTERN.search(content)) + + +async def _generate_llm_redirect( + bot, message: discord.Message, count: int, +) -> str | None: + """Ask the LLM chat model to generate an unblock-nag redirect.""" + 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"Unblock nag count: {count}\n" + f"What they said: {message.content[:300]}" + ) + + messages = [{"role": "user", "content": user_prompt}] + + effective_prompt = _UNBLOCK_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 unblock redirect generation failed") + return None + + if response: + response = _strip_brackets(response) + + return response if response else None + + +def _static_fallback(message: discord.Message, count: int) -> str: + """Pick a static template message as fallback.""" + if count >= 2: + pool = DEFAULT_UNBLOCK_NUDGES + else: + pool = DEFAULT_UNBLOCK_REMINDS + return random.choice(pool).format( + username=message.author.display_name, count=count, + ) + + +async def handle_unblock_nag( + bot, message: discord.Message, dirty_users: set[int], +): + """Handle a detected unblock-nagging message.""" + config = bot.config.get("unblock_nag", {}) + if not config.get("enabled", True): + 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", 30) + + if not tracker.can_unblock_remind(user_id, cooldown): + return + + count = tracker.record_unblock_nag(user_id) + action_type = "unblock_nudge" if count >= 2 else "unblock_remind" + + # Generate the redirect message + use_llm = config.get("use_llm", True) + redirect_text = None + if use_llm: + redirect_text = await _generate_llm_redirect(bot, message, count) + + if redirect_text: + _record_redirect(message.channel.id, redirect_text) + else: + redirect_text = _static_fallback(message, count) + + await message.channel.send(redirect_text) + + await log_action( + message.guild, + f"**UNBLOCK {'NUDGE' if count >= 2 else 'REMIND'}** | {message.author.mention} | " + f"Nag count: {count}", + ) + + logger.info("Unblock %s for %s (count %d)", action_type.replace("unblock_", ""), 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=None, + details=f"unblock_nag_count={count}", + )) + save_user_state(bot, dirty_users, user_id) diff --git a/config.yaml b/config.yaml index e410376..b74843c 100644 --- a/config.yaml +++ b/config.yaml @@ -36,6 +36,11 @@ topic_drift: escalation_count: 3 # After this many reminds, DM the server owner reset_minutes: 60 # Reset off-topic count after this much on-topic behavior +unblock_nag: + enabled: true + use_llm: true # Generate redirect messages via LLM instead of static templates + remind_cooldown_minutes: 30 # Don't remind same user more than once per this window + mention_scan: enabled: true scan_messages: 30 # Messages to scan per mention trigger diff --git a/prompts/unblock_redirect.txt b/prompts/unblock_redirect.txt new file mode 100644 index 0000000..e40d345 --- /dev/null +++ b/prompts/unblock_redirect.txt @@ -0,0 +1,7 @@ +You're the hall monitor of "Skill Issue Support Group" (gaming Discord). Someone is asking to be unblocked — again. +Write 1-2 sentences shutting it down. The message should make it clear that begging in chat won't help. + +- Snarky and playful, not cruel. Reference what they actually said — don't be vague. +- Casual, like a friend telling them to knock it off. If nag count is 2+, escalate the sass. +- The core message: block/unblock decisions are between them and the person who blocked them (or admins). Bringing it up in chat repeatedly is not going to change anything. +- Max 1 emoji. No hashtags, brackets, metadata, or AI references. \ No newline at end of file diff --git a/utils/drama_tracker.py b/utils/drama_tracker.py index 778cca8..c0dfada 100644 --- a/utils/drama_tracker.py +++ b/utils/drama_tracker.py @@ -29,6 +29,9 @@ class UserDrama: coherence_scores: list[float] = field(default_factory=list) baseline_coherence: float = 0.85 last_coherence_alert_time: float = 0.0 + # Unblock nagging tracking + unblock_nag_count: int = 0 + last_unblock_nag_time: float = 0.0 # Per-user LLM notes notes: str = "" # Known aliases/nicknames @@ -256,6 +259,21 @@ class DramaTracker: """Return {user_id: [aliases]} for all users that have aliases set.""" return {uid: user.aliases for uid, user in self._users.items() if user.aliases} + def record_unblock_nag(self, user_id: int) -> int: + user = self.get_user(user_id) + user.unblock_nag_count += 1 + user.last_unblock_nag_time = time.time() + return user.unblock_nag_count + + def can_unblock_remind(self, user_id: int, cooldown_minutes: int) -> bool: + user = self.get_user(user_id) + if user.last_unblock_nag_time == 0.0: + return True + return time.time() - user.last_unblock_nag_time > cooldown_minutes * 60 + + def get_unblock_nag_count(self, user_id: int) -> int: + return self.get_user(user_id).unblock_nag_count + def reset_off_topic(self, user_id: int) -> None: user = self.get_user(user_id) user.off_topic_count = 0