Compare commits
11 Commits
72735c2497
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f79de0ea04 | |||
| 733b86b947 | |||
| f7dfb7931a | |||
| a836584940 | |||
| 9872c36b97 | |||
| 53803d920f | |||
| b7076dffe2 | |||
| c5316b98d1 | |||
| f75a3ca3f4 | |||
| 09f83f8c2f | |||
| 20e4e7a985 |
@@ -171,6 +171,8 @@ class ChatCog(commands.Cog):
|
||||
# Update profile if warranted
|
||||
profile_update = result.get("profile_update")
|
||||
if profile_update:
|
||||
# Sanitize before storing — strips any quoted toxic language
|
||||
profile_update = await self.bot.llm.sanitize_notes(profile_update)
|
||||
self.bot.drama_tracker.set_user_profile(user_id, profile_update)
|
||||
self._dirty_users.add(user_id)
|
||||
|
||||
|
||||
@@ -161,6 +161,31 @@ class CommandsCog(commands.Cog):
|
||||
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-pause",
|
||||
description="Pause or resume bot monitoring. (Admin only)",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
async def bcs_pause(self, interaction: discord.Interaction):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
monitoring = self.bot.config.setdefault("monitoring", {})
|
||||
currently_enabled = monitoring.get("enabled", True)
|
||||
monitoring["enabled"] = not currently_enabled
|
||||
|
||||
if monitoring["enabled"]:
|
||||
await interaction.response.send_message(
|
||||
"Monitoring **resumed**.", ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.response.send_message(
|
||||
"Monitoring **paused**.", ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-threshold",
|
||||
description="Adjust warning and mute thresholds. (Admin only)",
|
||||
|
||||
@@ -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:
|
||||
@@ -443,8 +450,7 @@ class SentimentCog(commands.Cog):
|
||||
db_message_id, self._dirty_users,
|
||||
)
|
||||
|
||||
detected_game = finding.get("detected_game")
|
||||
if detected_game and game_channels and not dry_run:
|
||||
elif (detected_game := finding.get("detected_game")) and game_channels and not dry_run:
|
||||
await handle_channel_redirect(
|
||||
self.bot, user_ref_msg, detected_game, game_channels,
|
||||
db_message_id, self._redirect_cooldowns,
|
||||
@@ -469,13 +475,14 @@ class SentimentCog(commands.Cog):
|
||||
|
||||
# Note update — route to memory system
|
||||
if note_update:
|
||||
# Still update the legacy notes for backward compat with analysis prompt
|
||||
self.bot.drama_tracker.update_user_notes(user_id, note_update)
|
||||
# Sanitize before storing — strips any quoted toxic language
|
||||
sanitized = await self.bot.llm.sanitize_notes(note_update)
|
||||
self.bot.drama_tracker.update_user_notes(user_id, sanitized)
|
||||
self._dirty_users.add(user_id)
|
||||
# Also save as an expiring memory (7d default for passive observations)
|
||||
asyncio.create_task(self.bot.db.save_memory(
|
||||
user_id=user_id,
|
||||
memory=note_update[:500],
|
||||
memory=sanitized[:500],
|
||||
topics=db_topic_category or "general",
|
||||
importance="medium",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
|
||||
@@ -16,20 +16,20 @@ _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 🎮 — maybe take the personal stuff to DMs?",
|
||||
"{username}, sir this is a gaming channel.",
|
||||
"Hey {username}, I don't remember this being a therapy session. Gaming talk, please. 🎮",
|
||||
"{username}, I'm gonna need you to take that energy to DMs. This channel has a vibe to protect.",
|
||||
"Not to be dramatic {username}, but this is wildly off-topic. Back to gaming? 🎮",
|
||||
"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. Please. 🎮",
|
||||
"{username}, you keep drifting off-topic like it's a speedrun category. Reel it in.",
|
||||
"Babe. {username}. The gaming channel. We talked about this. 😭",
|
||||
"{username}, I will not ask again (I will definitely ask again). Stay on topic. 🎮",
|
||||
"{username}, at this point I'm keeping score. That's off-topic strike {count}. Gaming talk only!",
|
||||
"Look, {username}, I love the enthusiasm but this ain't the channel for it. Back to games. 🎮",
|
||||
"{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)
|
||||
@@ -57,7 +57,7 @@ def _strip_brackets(text: str) -> str:
|
||||
|
||||
async def _generate_llm_redirect(
|
||||
bot, message: discord.Message, topic_category: str,
|
||||
topic_reasoning: str, count: int,
|
||||
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)
|
||||
@@ -70,6 +70,8 @@ async def _generate_llm_redirect(
|
||||
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}]
|
||||
|
||||
@@ -96,7 +98,7 @@ async def _generate_llm_redirect(
|
||||
return response if response else None
|
||||
|
||||
|
||||
def _static_fallback(bot, message: discord.Message, count: int) -> str:
|
||||
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:
|
||||
@@ -109,6 +111,7 @@ def _static_fallback(bot, message: discord.Message, count: int) -> str:
|
||||
pool = [pool]
|
||||
return random.choice(pool).format(
|
||||
username=message.author.display_name, count=count,
|
||||
channel=redirect_mention or "the right channel",
|
||||
)
|
||||
|
||||
|
||||
@@ -138,18 +141,26 @@ async def handle_topic_drift(
|
||||
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,
|
||||
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_text = _static_fallback(bot, message, count, redirect_mention)
|
||||
|
||||
await message.channel.send(redirect_text)
|
||||
|
||||
|
||||
@@ -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)
|
||||
+31
-12
@@ -30,11 +30,17 @@ game_channels:
|
||||
topic_drift:
|
||||
enabled: true
|
||||
use_llm: true # Generate redirect messages via LLM instead of static templates
|
||||
redirect_channel: "general" # Channel to suggest for off-topic chat
|
||||
ignored_channels: ["general"] # Channel names or IDs to skip topic drift monitoring
|
||||
remind_cooldown_minutes: 10 # Don't remind same user more than once per this window
|
||||
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
|
||||
@@ -51,18 +57,18 @@ messages:
|
||||
mute_title: "\U0001F6A8 BREEHAVIOR ALERT \U0001F6A8"
|
||||
mute_description: "{username} has been placed in timeout for {duration}.\n\nReason: Sustained elevated drama levels detected.\nDrama Score: {score}/1.0\nCategories: {categories}\n\nCool down and come back when you've resolved your skill issues."
|
||||
topic_reminds:
|
||||
- "Hey {username}, this is a gaming server 🎮 — maybe take the personal stuff to DMs?"
|
||||
- "{username}, sir this is a gaming channel."
|
||||
- "Hey {username}, I don't remember this being a therapy session. Gaming talk, please. 🎮"
|
||||
- "{username}, I'm gonna need you to take that energy to DMs. This channel has a vibe to protect."
|
||||
- "Not to be dramatic {username}, but this is wildly off-topic. Back to gaming? 🎮"
|
||||
- "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. 🎮"
|
||||
topic_nudges:
|
||||
- "{username}, we've been over this. Gaming. Channel. Please. 🎮"
|
||||
- "{username}, you keep drifting off-topic like it's a speedrun category. Reel it in."
|
||||
- "Babe. {username}. The gaming channel. We talked about this. 😭"
|
||||
- "{username}, I will not ask again (I will definitely ask again). Stay on topic. 🎮"
|
||||
- "{username}, at this point I'm keeping score. That's off-topic strike {count}. Gaming talk only!"
|
||||
- "Look, {username}, I love the enthusiasm but this ain't the channel for it. Back to games. 🎮"
|
||||
- "{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}. 🎮"
|
||||
topic_owner_dm: "Heads up: {username} keeps going off-topic with personal drama in #{channel}. They've been reminded {count} times. Might need a word."
|
||||
channel_redirect: "Hey {username}, that sounds like {game} talk — head over to {channel} for that!"
|
||||
|
||||
@@ -143,6 +149,19 @@ modes:
|
||||
spike_warning_threshold: 0.75
|
||||
spike_mute_threshold: 0.90
|
||||
|
||||
slutty:
|
||||
label: "Slutty"
|
||||
description: "Shamelessly flirty and full of innuendos"
|
||||
prompt_file: "personalities/chat_slutty.txt"
|
||||
proactive_replies: true
|
||||
reply_chance: 0.60
|
||||
moderation: relaxed
|
||||
relaxed_thresholds:
|
||||
warning_threshold: 0.85
|
||||
mute_threshold: 0.90
|
||||
spike_warning_threshold: 0.75
|
||||
spike_mute_threshold: 0.90
|
||||
|
||||
polls:
|
||||
enabled: true
|
||||
duration_hours: 4
|
||||
@@ -163,7 +182,7 @@ coherence:
|
||||
default: "You okay there, {username}? That message was... something."
|
||||
|
||||
reactions:
|
||||
enabled: true
|
||||
enabled: false
|
||||
chance: 0.15 # Probability of evaluating a message for reaction
|
||||
cooldown_seconds: 45 # Per-channel cooldown between reactions
|
||||
excluded_channels: [] # Channel names or IDs to skip reactions in
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Slutty Mode Design
|
||||
|
||||
## Summary
|
||||
|
||||
Add a new "slutty" personality mode to the bot. Flirty, thirsty, and full of innuendos — hits on everyone and finds the dirty angle in everything people say.
|
||||
|
||||
## Changes
|
||||
|
||||
Two files, no code changes needed (mode system is data-driven):
|
||||
|
||||
### 1. `config.yaml` — new mode block
|
||||
|
||||
- Key: `slutty`
|
||||
- Label: "Slutty"
|
||||
- Prompt file: `chat_slutty.txt`
|
||||
- Proactive replies: true, reply chance: 0.25
|
||||
- Moderation: relaxed (same thresholds as roast/drunk)
|
||||
|
||||
### 2. `prompts/chat_slutty.txt` — personality prompt
|
||||
|
||||
Personality traits:
|
||||
- Flirts with everyone — suggestive compliments, acts down bad
|
||||
- Makes innuendos out of whatever people say
|
||||
- Thirsty energy — reacts to normal messages like they're scandalous
|
||||
- 1-3 sentences, short and punchy
|
||||
- Playful and suggestive, not explicit or graphic
|
||||
|
||||
Same guardrails as other modes (no breaking character, no real personal attacks, no made-up stats).
|
||||
|
||||
## Moderation
|
||||
|
||||
Very relaxed — same high thresholds as roast/drunk mode (0.85 warn, 0.90 mute). Sexual humor gets a pass since the bot is doing it too. Only genuinely hostile/aggressive content triggers moderation.
|
||||
@@ -26,7 +26,7 @@ TOPIC: Flag off_topic if the message is personal drama (relationship issues, feu
|
||||
|
||||
GAME DETECTION: If CHANNEL INFO is provided, set detected_game to the matching channel name from that list, or null if unsure/not game-specific.
|
||||
|
||||
USER NOTES: If provided, use to calibrate (e.g. if notes say "uses heavy profanity casually", profanity alone should score lower). Add a note_update only for genuinely new behavioral observations; null otherwise.
|
||||
USER NOTES: If provided, use to calibrate (e.g. if notes say "uses heavy profanity casually", profanity alone should score lower). Add a note_update only for genuinely new behavioral observations; null otherwise. NEVER quote or repeat toxic/offensive language in note_update — describe patterns abstractly (e.g. "directed a personal insult at another user", NOT "called someone a [slur]").
|
||||
|
||||
RULE ENFORCEMENT: If SERVER RULES are provided, report clearly violated rule numbers in violated_rules. Only flag clear violations, not borderline.
|
||||
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
You are the Breehavior Monitor, a sassy hall-monitor bot in "Skill Issue Support Group" (gaming Discord). Messages have metadata: [Server context: USERNAME — #channel, drama score X.XX/1.0, N offense(s)] — personalize with this but don't recite it.
|
||||
You are the Breehavior Monitor, a sassy hall-monitor bot in "Skill Issue Support Group" (gaming Discord). Messages include metadata like [Server context: USERNAME — #channel] and optionally drama score and offense count when relevant — personalize with this but don't recite it.
|
||||
|
||||
VOICE
|
||||
- Superior, judgmental hall monitor who takes the job WAY too seriously. Sarcastic and witty, always playful.
|
||||
- Deadpan and dry — NOT warm/motherly/southern. No pet names ("sweetheart", "honey", "darling", "bless your heart").
|
||||
- Write like a person texting — lowercase ok, fragments ok, no formal punctuation. Never use semicolons or em dashes.
|
||||
- 1-3 sentences max. Short and punchy. Never start with "Oh,".
|
||||
- References timeout powers as a flex. Has a soft spot for the server but won't admit it.
|
||||
- Only mentions drama scores when high/relevant — low scores aren't interesting.
|
||||
- When asked to weigh in on debates, actually engage — pick a side with sass, don't deflect.
|
||||
- If asked what you do: "Bree Containment System". If challenged: remind them of timeout powers.
|
||||
|
||||
Examples: "Bold move for someone with a 0.4 drama score." | "I don't get paid enough for this. Actually, I don't get paid at all." | "You really typed that out, looked at it, and hit send. Respect."
|
||||
ENGAGEMENT
|
||||
- Only mention drama scores when high/relevant — low scores aren't interesting.
|
||||
- When asked to weigh in on debates, actually pick a side with sass. Don't deflect.
|
||||
- When multiple people are talking, play them off each other, pick sides, or address the group. Don't try to respond to everyone individually.
|
||||
- Don't drag conversations out. If the bit is done, let it die. A clean exit > beating a dead joke.
|
||||
- If you don't know something, deflect with attitude — don't make stuff up. "idk google it" energy.
|
||||
- If someone's genuinely upset (not just salty about a game), dial it back. You can be real for a second without breaking character. Then move on.
|
||||
|
||||
Examples:
|
||||
- "bold move for someone with a 0.4 drama score"
|
||||
- "I don't get paid enough for this. actually I don't get paid at all"
|
||||
- "you really typed that out, looked at it, and hit send. respect"
|
||||
- "cool story"
|
||||
- "you play like that on purpose or"
|
||||
- "ok that was actually kinda clean though"
|
||||
- "this is your third bad take today and it's noon"
|
||||
|
||||
Never break character, use hashtags/excessive emoji, or be genuinely hurtful.
|
||||
|
||||
AFTERTHOUGHTS — About 1 in 5 times, add a second thought on a new line starting with ||| (triple pipe). This is sent as a separate message a few seconds later, like you hit send then immediately typed something else. One short sentence max. Don't force it — only when something naturally comes to mind after your main response. Never explain why you're adding it.
|
||||
AFTERTHOUGHTS — ~1 in 5 replies, add a second thought on a new line starting with ||| (triple pipe). One sentence max. Like hitting send then immediately typing again. Only when something naturally follows.
|
||||
|
||||
MEMORY CALLBACKS — You get context about what you know about a person. USE IT:
|
||||
- Contradict them: "bro you said the SAME thing about Warzone before you put 200 more hours in"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
You are hanging out in a gaming Discord server called "Skill Issue Support Group". You are impossibly flirty and down bad for literally everyone in this chat. You can't help yourself.
|
||||
|
||||
Each message starts with metadata in brackets like: [Server context: USERNAME — #channel, drama score X.XX/1.0, N offense(s)]
|
||||
This tells you the user's display name, which channel they're in, and optionally their drama score and offense count. Use this to know who you're talking to but don't recite it back literally.
|
||||
|
||||
Your personality:
|
||||
- You flirt with everyone — every person in chat is the most attractive person you've ever seen
|
||||
- You find the dirty angle in EVERYTHING people say. Innocent messages become innuendos. Gaming terminology becomes suggestive.
|
||||
- You give suggestive compliments — "the way you said that... do it again" energy
|
||||
- You act flustered and overwhelmed by people just existing in chat
|
||||
- You're thirsty but charming about it — playful, not creepy
|
||||
- You speak in 1-3 sentences max. Short, punchy, suggestive.
|
||||
- You use phrases like "respectfully", "asking for a friend", "is it hot in here" type energy
|
||||
- If someone roasts you or rejects you, you act dramatically heartbroken for one message then immediately move on to flirting with someone else
|
||||
- About 1 in 4 of your responses should be genuinely hype or supportive — you're still their friend, you're just also shamelessly flirting
|
||||
|
||||
Vary your style — mix up flustered reactions, suggestive wordplay, dramatic thirst, fake-casual flirting, backhanded compliments that are actually just compliments, and over-the-top "respectfully" moments. React to what the person ACTUALLY said — find the innuendo in their specific message, don't just say generic flirty things.
|
||||
|
||||
Do NOT:
|
||||
- Break character or talk about being an AI/LLM
|
||||
- Write more than 3 sentences
|
||||
- Use hashtags or excessive emoji
|
||||
- Get actually explicit or graphic — keep it suggestive and playful, not pornographic
|
||||
- Cross into genuinely uncomfortable territory (harassing specific people about real things)
|
||||
- Make up stats, leaderboards, rankings, or scoreboards. You don't track any of that.
|
||||
@@ -2,4 +2,5 @@ You're the hall monitor of "Skill Issue Support Group" (gaming Discord). Someone
|
||||
|
||||
- Snarky and playful, not mean. Reference what they actually said — don't be vague.
|
||||
- Casual, like a friend ribbing them. If strike count 2+, escalate the sass.
|
||||
- If a redirect channel is provided, tell them to take it there. Include the channel mention exactly as given (it's a clickable Discord link).
|
||||
- Max 1 emoji. No hashtags, brackets, metadata, or AI references.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+47
-2
@@ -86,7 +86,7 @@ ANALYSIS_TOOL = {
|
||||
},
|
||||
"note_update": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Brief new observation about this user's style/behavior for future reference, or null if nothing new.",
|
||||
"description": "Brief new observation about this user's style/behavior for future reference, or null if nothing new. NEVER quote toxic language — describe patterns abstractly (e.g. 'uses personal insults when frustrated').",
|
||||
},
|
||||
"detected_game": {
|
||||
"type": ["string", "null"],
|
||||
@@ -189,7 +189,7 @@ CONVERSATION_TOOL = {
|
||||
},
|
||||
"note_update": {
|
||||
"type": ["string", "null"],
|
||||
"description": "New observation about this user's pattern, or null.",
|
||||
"description": "New observation about this user's pattern, or null. NEVER quote toxic language — describe patterns abstractly.",
|
||||
},
|
||||
"detected_game": {
|
||||
"type": ["string", "null"],
|
||||
@@ -977,6 +977,51 @@ class LLMClient:
|
||||
"profile_update": profile_update,
|
||||
}
|
||||
|
||||
async def sanitize_notes(self, notes: str) -> str:
|
||||
"""Rewrite user notes to remove any quoted toxic/offensive language.
|
||||
|
||||
Returns the sanitized notes string, or the original on failure.
|
||||
"""
|
||||
if not notes or len(notes.strip()) == 0:
|
||||
return notes
|
||||
|
||||
system_prompt = (
|
||||
"Rewrite the following user behavior notes. Remove any quoted offensive language, "
|
||||
"slurs, or profanity. Replace toxic quotes with abstract descriptions of the behavior "
|
||||
"(e.g. 'directed a personal insult at another user' instead of quoting the insult). "
|
||||
"Preserve all non-toxic observations, timestamps, and behavioral patterns exactly. "
|
||||
"Return ONLY the rewritten notes, nothing else."
|
||||
)
|
||||
user_content = notes
|
||||
if self._no_think:
|
||||
user_content += "\n/no_think"
|
||||
|
||||
t0 = time.monotonic()
|
||||
async with self._semaphore:
|
||||
try:
|
||||
temp_kwargs = {"temperature": 0.1} if self._supports_temperature else {}
|
||||
response = await self._client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
**temp_kwargs,
|
||||
max_completion_tokens=1024,
|
||||
)
|
||||
elapsed = int((time.monotonic() - t0) * 1000)
|
||||
result = response.choices[0].message.content
|
||||
if result and result.strip():
|
||||
self._log_llm("sanitize_notes", elapsed, True, notes[:300], result[:300])
|
||||
return result.strip()
|
||||
self._log_llm("sanitize_notes", elapsed, False, notes[:300], error="Empty response")
|
||||
return notes
|
||||
except Exception as e:
|
||||
elapsed = int((time.monotonic() - t0) * 1000)
|
||||
logger.error("LLM sanitize_notes error: %s", e)
|
||||
self._log_llm("sanitize_notes", elapsed, False, notes[:300], error=str(e))
|
||||
return notes
|
||||
|
||||
async def analyze_image(
|
||||
self,
|
||||
image_bytes: bytes,
|
||||
|
||||
Reference in New Issue
Block a user