Files
Breehavior-Monitor/cogs/sentiment/unblock_nag.py
AJ Isaacs f79de0ea04 feat: add unblock-nag detection and redirect
Keyword-based detection for users repeatedly asking to be unblocked in
chat. Fires an LLM-generated snarky redirect (with static fallback),
tracks per-user nag count with escalating sass, and respects a 30-min
cooldown. Configurable via config.yaml unblock_nag section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:19:29 -04:00

162 lines
5.5 KiB
Python

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)