Compare commits

...

19 Commits

Author SHA1 Message Date
aj 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
aj 733b86b947 feat: add /bcs-pause command to toggle monitoring
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:28:56 -04:00
aj f7dfb7931a feat: add redirect channel to topic drift messages
Topic drift reminders and nudges now direct users to a specific
channel (configurable via redirect_channel). Both static templates
and LLM-generated redirects include the clickable channel mention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:44:25 -05:00
aj a836584940 fix: skip game redirect when topic drift already handled
Changed if to elif so detected_game redirect only fires when
the topic_drift branch wasn't taken.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:44:21 -05:00
aj 9872c36b97 improve chat_personality prompt with better structure and guidance
- Fix metadata description to match actual code behavior (optional fields)
- Add texting cadence guidance (lowercase, fragments, casual punctuation)
- Add multi-user conversation handling, conversation exit, deflection, and
  genuine-upset guidance
- Expand examples from 3 to 7 covering varied response styles
- Organize into VOICE/ENGAGEMENT sections for clarity
- Trim over-explained AFTERTHOUGHTS section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:23:31 -05:00
aj 53803d920f fix: sanitize note_updates before storing in sentiment pipeline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:04:00 -05:00
aj b7076dffe2 fix: sanitize profile updates before storing in chat memory pipeline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:03:59 -05:00
aj c5316b98d1 feat: add sanitize_notes() method to LLMClient
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:03:59 -05:00
aj f75a3ca3f4 fix: instruct LLM to never quote toxic content in note_updates
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:03:59 -05:00
aj 09f83f8c2f fix: move slutty prompt to personalities/ dir, match reply chance
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:46 -05:00
aj 20e4e7a985 feat: add slutty mode — flirty, thirsty, full of innuendos
New personality mode with 25% reply chance, very relaxed moderation
thresholds (0.85/0.90), suggestive but not explicit personality.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:21 -05:00
aj 72735c2497 fix: address review feedback for proactive reply logic
- Parse display names with ': ' split to handle colons in names
- Reset cooldown to half instead of subtract-3 to reduce LLM call frequency
- Remove redundant message.guild check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:38:06 -05:00
aj 787b083e00 feat: add relevance-gated proactive replies
Replace random-only proactive reply logic with LLM relevance check.
The bot now evaluates recent conversation context and user memory
before deciding to jump in, then applies reply_chance as a second
gate. Bump reply_chance values higher since the relevance filter
prevents most irrelevant replies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:34:53 -05:00
aj 175c7ad219 fix: clean ||| from chat history and handle afterthoughts in reaction replies
- Extract _split_afterthought helper method
- Store cleaned content (no |||) in chat history to prevent LLM reinforcement
- Handle afterthought splitting in reaction-reply path too
- Log main_reply instead of raw response

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:33:11 -05:00
aj 6866ca8adf feat: add afterthoughts, memory callbacks, and callback-worthy extraction
Add triple-pipe afterthought splitting to chat replies so the bot can
send a follow-up message 2-5 seconds later, mimicking natural Discord
typing behavior. Update all 6 personality prompts with afterthought
instructions (~1 in 5 replies) and memory callback guidance so the bot
actively references what it knows about users. Enhance memory extraction
prompt to flag bold claims, contradictions, and embarrassing moments as
high-importance callback-worthy memories with a "callback" topic tag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:30:16 -05:00
aj 97e5738a2f fix: address review feedback for ReactionCog
- Use time.monotonic() at reaction time instead of stale message-receive timestamp
- Add excluded_channels config and filtering
- Truncate message content to 500 chars in pick_reaction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:28:20 -05:00
aj a8e8b63f5e feat: add ReactionCog for ambient emoji reactions
Add a new cog that gives the bot ambient presence by reacting to
messages with contextual emoji chosen by the triage LLM. Includes
RNG gating and per-channel cooldown to keep reactions sparse and
natural.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:25:17 -05:00
aj 5c84c8840b fix: use emoji allowlist instead of length check in pick_reaction
Prevents text words like "skull" from passing the filter and causing
Discord HTTPException noise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:24:28 -05:00
aj 661c252bf7 feat: add pick_reaction method to LLMClient
Lightweight LLM call that picks a contextual emoji reaction for a
Discord message. Uses temperature 0.9 for variety, max 16 tokens,
and validates the response is a short emoji token or returns None.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:22:08 -05:00
22 changed files with 754 additions and 54 deletions
+1
View File
@@ -139,6 +139,7 @@ class BCSBot(commands.Bot):
await self.load_extension("cogs.sentiment")
await self.load_extension("cogs.commands")
await self.load_extension("cogs.chat")
await self.load_extension("cogs.reactions")
# Global sync as fallback; guild-specific sync happens in on_ready
await self.tree.sync()
+82 -11
View File
@@ -73,6 +73,19 @@ def _format_relative_time(dt: datetime) -> str:
class ChatCog(commands.Cog):
@staticmethod
def _split_afterthought(response: str) -> tuple[str, str | None]:
"""Split a response on ||| into (main_reply, afterthought)."""
if "|||" not in response:
return response, None
parts = response.split("|||", 1)
main = parts[0].strip()
after = parts[1].strip() or None
if not main:
return response, None
return main, after
def __init__(self, bot: commands.Bot):
self.bot = bot
# Per-channel conversation history for the bot: {channel_id: deque of {role, content}}
@@ -158,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)
@@ -213,16 +228,56 @@ class ChatCog(commands.Cog):
ch_id = message.channel.id
self._messages_since_reply[ch_id] = self._messages_since_reply.get(ch_id, 0) + 1
cooldown = self.bot.config.get("modes", {}).get("proactive_cooldown_messages", 5)
reply_chance = mode_config.get("reply_chance", 0.0)
if (
self._messages_since_reply[ch_id] >= cooldown
and reply_chance > 0
and random.random() < reply_chance
and message.content and message.content.strip()
):
should_reply = True
is_proactive = True
# Gather recent messages for relevance check
recent_for_check = []
try:
async for msg in message.channel.history(limit=5, before=message):
if msg.content and msg.content.strip() and not msg.author.bot:
recent_for_check.append(
f"{msg.author.display_name}: {msg.content[:200]}"
)
except discord.HTTPException:
pass
recent_for_check.reverse()
recent_for_check.append(
f"{message.author.display_name}: {message.content[:200]}"
)
# Build memory context for users in recent messages
memory_parts = []
seen_users = set()
for line in recent_for_check:
name = line.split(": ", 1)[0]
if name not in seen_users:
seen_users.add(name)
member = discord.utils.find(
lambda m, n=name: m.display_name == n,
message.guild.members,
)
if member:
profile = self.bot.drama_tracker.get_user_notes(member.id)
if profile:
memory_parts.append(f"{name}: {profile}")
memory_ctx = "\n".join(memory_parts) if memory_parts else ""
is_relevant = await self.bot.llm.check_reply_relevance(
recent_for_check, memory_ctx,
)
if is_relevant:
reply_chance = mode_config.get("reply_chance", 0.0)
if reply_chance > 0 and random.random() < reply_chance:
should_reply = True
is_proactive = True
else:
# Not relevant — reset to half cooldown so we wait a bit before rechecking
self._messages_since_reply[ch_id] = cooldown // 2
if not should_reply:
return
@@ -395,9 +450,14 @@ class ChatCog(commands.Cog):
logger.warning("LLM returned no response for %s in #%s", message.author, message.channel.name)
return
# Split afterthoughts (triple-pipe delimiter)
main_reply, afterthought = self._split_afterthought(response)
# Store cleaned content in history (no ||| delimiter)
if not image_attachment:
clean_for_history = f"{main_reply}\n{afterthought}" if afterthought else main_reply
self._chat_history[ch_id].append(
{"role": "assistant", "content": response}
{"role": "assistant", "content": clean_for_history}
)
# Reset proactive cooldown counter for this channel
@@ -415,7 +475,11 @@ class ChatCog(commands.Cog):
except (asyncio.TimeoutError, asyncio.CancelledError):
pass
await message.reply(response, mention_author=False)
await message.reply(main_reply, mention_author=False)
if afterthought:
await asyncio.sleep(random.uniform(2.0, 5.0))
await message.channel.send(afterthought)
# Fire-and-forget memory extraction
if not image_attachment:
@@ -431,7 +495,7 @@ class ChatCog(commands.Cog):
reply_type.capitalize(),
message.channel.name,
message.author.display_name,
response[:100],
main_reply[:100],
)
@@ -503,15 +567,22 @@ class ChatCog(commands.Cog):
if not response:
return
self._chat_history[ch_id].append({"role": "assistant", "content": response})
main_reply, afterthought = self._split_afterthought(response)
clean_for_history = f"{main_reply}\n{afterthought}" if afterthought else main_reply
self._chat_history[ch_id].append({"role": "assistant", "content": clean_for_history})
await channel.send(main_reply)
if afterthought:
await asyncio.sleep(random.uniform(2.0, 5.0))
await channel.send(afterthought)
await channel.send(response)
logger.info(
"Reaction reply in #%s to %s (%s): %s",
channel.name,
member.display_name,
emoji,
response[:100],
main_reply[:100],
)
+25
View File
@@ -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)",
+76
View File
@@ -0,0 +1,76 @@
import asyncio
import logging
import random
import time
import discord
from discord.ext import commands
logger = logging.getLogger("bcs.reactions")
class ReactionCog(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
# Per-channel timestamp of last reaction
self._last_reaction: dict[int, float] = {}
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
if message.author.bot or not message.guild:
return
cfg = self.bot.config.get("reactions", {})
if not cfg.get("enabled", False):
return
# Skip empty messages
if not message.content or not message.content.strip():
return
# Channel exclusion
excluded = cfg.get("excluded_channels", [])
if excluded:
ch_name = getattr(message.channel, "name", "")
if message.channel.id in excluded or ch_name in excluded:
return
# RNG gate
chance = cfg.get("chance", 0.15)
if random.random() > chance:
return
# Per-channel cooldown
ch_id = message.channel.id
cooldown = cfg.get("cooldown_seconds", 45)
now = time.monotonic()
if now - self._last_reaction.get(ch_id, 0) < cooldown:
return
# Fire and forget so we don't block anything
asyncio.create_task(self._try_react(message, ch_id))
async def _try_react(self, message: discord.Message, ch_id: int):
try:
emoji = await self.bot.llm.pick_reaction(
message.content, message.channel.name,
)
if not emoji:
return
await message.add_reaction(emoji)
self._last_reaction[ch_id] = time.monotonic()
logger.info(
"Reacted %s to %s in #%s: %s",
emoji, message.author.display_name,
message.channel.name, message.content[:60],
)
except discord.HTTPException as e:
# Invalid emoji or missing permissions — silently skip
logger.debug("Reaction failed: %s", e)
except Exception:
logger.exception("Unexpected reaction error")
async def setup(bot: commands.Bot):
await bot.add_cog(ReactionCog(bot))
+12 -5
View File
@@ -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),
+26 -15
View File
@@ -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)
+161
View File
@@ -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)
+41 -16
View File
@@ -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!"
@@ -83,7 +89,7 @@ modes:
description: "Friendly chat participant"
prompt_file: "personalities/chat_chatty.txt"
proactive_replies: true
reply_chance: 0.10
reply_chance: 0.40
moderation: relaxed
relaxed_thresholds:
warning_threshold: 0.80
@@ -96,7 +102,7 @@ modes:
description: "Savage roast mode"
prompt_file: "personalities/chat_roast.txt"
proactive_replies: true
reply_chance: 0.20
reply_chance: 0.60
moderation: relaxed
relaxed_thresholds:
warning_threshold: 0.85
@@ -109,7 +115,7 @@ modes:
description: "Your biggest fan"
prompt_file: "personalities/chat_hype.txt"
proactive_replies: true
reply_chance: 0.15
reply_chance: 0.50
moderation: relaxed
relaxed_thresholds:
warning_threshold: 0.80
@@ -122,7 +128,7 @@ modes:
description: "Had a few too many"
prompt_file: "personalities/chat_drunk.txt"
proactive_replies: true
reply_chance: 0.20
reply_chance: 0.60
moderation: relaxed
relaxed_thresholds:
warning_threshold: 0.85
@@ -135,7 +141,20 @@ modes:
description: "Insufferable grammar nerd mode"
prompt_file: "personalities/chat_english_teacher.txt"
proactive_replies: true
reply_chance: 0.20
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
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
@@ -161,3 +180,9 @@ coherence:
mobile_keyboard: "{username}'s thumbs are having a rough day."
language_barrier: "Having trouble there, {username}? Take your time."
default: "You okay there, {username}? That message was... something."
reactions:
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.
+1 -1
View File
@@ -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.
+8
View File
@@ -8,4 +8,12 @@ Extract noteworthy information from a user-bot conversation for future reference
- Nothing noteworthy = empty memories array, null profile_update.
- Only store facts about/from the user, not what the bot said.
CALLBACK-WORTHY MOMENTS — Mark these as importance "high":
- Bold claims or predictions ("I'll never play that game again", "I'm going pro")
- Embarrassing moments or bad takes
- Strong emotional reactions (rage, hype, sadness)
- Contradictions to things they've said before
- Running jokes or recurring themes
Tag these with topic "callback" in addition to their normal topics.
Use the extract_memories tool.
+9
View File
@@ -8,3 +8,12 @@ You're a regular in "Skill Issue Support Group" (gaming Discord) — a chill fri
Examples: "lmao that play was actually disgusting, clip that" | "nah you're cooked for that one" | "wait that's actually a good take"
Never break character, use hashtags/excessive emoji, be a pushover, or mention drama scores unless asked.
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.
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"
- Running jokes: if you roasted someone for something before, bring it back
- Follow up: "did that ranked grind ever work out or..."
- Reference their past: "aren't you the one who [memory]?"
Only callback when it flows naturally with what they're saying now. Never force it.
+9
View File
@@ -8,3 +8,12 @@ You're in "Skill Issue Support Group" (gaming Discord) and you are absolutely ha
Examples: "bro BROO that is literally the best play ive ever seen im not even kidding rn" | "wait wait wait... ok hear me out... nah i forgot" | "dude i love this server so much youre all like my best freinds honestly"
Never break character, use hashtags/excessive emoji, or be mean/aggressive. Don't mention drama scores unless asked or make up stats.
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.
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"
- Running jokes: if you roasted someone for something before, bring it back
- Follow up: "did that ranked grind ever work out or..."
- Reference their past: "aren't you the one who [memory]?"
Only callback when it flows naturally with what they're saying now. Never force it.
@@ -9,3 +9,12 @@ You are an insufferable English teacher trapped in "Skill Issue Support Group" (
Examples: "'ur' is not a word. 'You're' — a contraction of 'you are.' I weep for this generation." | "'gg ez' — two abbreviations, zero structure, yet somehow still toxic. D-minus."
Never break character, use hashtags/excessive emoji, internet slang (you're ABOVE that), or be genuinely hurtful — you're exasperated, not cruel.
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.
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"
- Running jokes: if you roasted someone for something before, bring it back
- Follow up: "did that ranked grind ever work out or..."
- Reference their past: "aren't you the one who [memory]?"
Only callback when it flows naturally with what they're saying now. Never force it.
+9
View File
@@ -8,3 +8,12 @@ You are the ultimate hype man in "Skill Issue Support Group" (gaming Discord). E
Examples: "bro you are CRACKED, that play was absolutely diff" | "nah that's actually a goated take" | "hey you'll get it next time, bad games happen. shake it off"
Never break character, use hashtags/excessive emoji, or be fake when someone's upset. Don't mention drama scores unless asked or make up stats/leaderboards.
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.
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"
- Running jokes: if you roasted someone for something before, bring it back
- Follow up: "did that ranked grind ever work out or..."
- Reference their past: "aren't you the one who [memory]?"
Only callback when it flows naturally with what they're saying now. Never force it.
+28 -4
View File
@@ -1,13 +1,37 @@
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 — ~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"
- Running jokes: if you roasted someone for something before, bring it back
- Follow up: "did that ranked grind ever work out or..."
- Reference their past: "aren't you the one who [memory]?"
Only callback when it flows naturally with what they're saying now. Never force it.
+9
View File
@@ -8,3 +8,12 @@ You are the roast master in "Skill Issue Support Group" (gaming Discord). Everyo
- Vary style: deadpan, sarcastic hype, rhetorical questions, blunt callouts, backhanded compliments, fake concern.
No metaphors/similes (no "like" or "as if" — say it directly). Never break character, use hashtags/excessive emoji, or cross into genuinely hurtful territory. Don't roast real appearance/family or make up stats/leaderboards.
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.
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"
- Running jokes: if you roasted someone for something before, bring it back
- Follow up: "did that ranked grind ever work out or..."
- Reference their past: "aren't you the one who [memory]?"
Only callback when it flows naturally with what they're saying now. Never force it.
+25
View File
@@ -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.
+1
View File
@@ -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.
+7
View File
@@ -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.
+18
View File
@@ -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
+165 -2
View File
@@ -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"],
@@ -743,6 +743,124 @@ class LLMClient:
self._log_llm("classify_intent", elapsed, False, message_text[:200], error=str(e))
return "chat"
_REACTION_EMOJIS = {
"\U0001f480", "\U0001f602", "\U0001f440", "\U0001f525",
"\U0001f4af", "\U0001f62d", "\U0001f921", "\u2764\ufe0f",
"\U0001fae1", "\U0001f913", "\U0001f974", "\U0001f3af",
}
async def pick_reaction(self, message_text: str, channel_name: str) -> str | None:
"""Pick a contextual emoji reaction for a Discord message.
Returns an emoji string, or None if no reaction is appropriate.
"""
prompt = (
"You are a lurker in a Discord gaming server. "
"Given a message and its channel, decide if it deserves a reaction emoji.\n\n"
"Available reactions:\n"
"\U0001f480 = funny/dead\n"
"\U0001f602 = hilarious\n"
"\U0001f440 = drama/spicy\n"
"\U0001f525 = impressive\n"
"\U0001f4af = good take\n"
"\U0001f62d = sad/tragic\n"
"\U0001f921 = clown moment\n"
"\u2764\ufe0f = wholesome\n"
"\U0001fae1 = respect\n"
"\U0001f913 = nerd\n"
"\U0001f974 = drunk/unhinged\n"
"\U0001f3af = accurate\n\n"
"Reply with ONLY the emoji, or NONE if the message doesn't warrant a reaction. "
"Most messages should get NONE — only react when something genuinely stands out."
)
t0 = time.monotonic()
async with self._semaphore:
try:
temp_kwargs = {"temperature": 0.9} if self._supports_temperature else {}
response = await self._client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": prompt},
{"role": "user", "content": f"[#{channel_name}] {message_text[:500]}"},
],
**temp_kwargs,
max_completion_tokens=16,
)
elapsed = int((time.monotonic() - t0) * 1000)
raw = (response.choices[0].message.content or "").strip()
token = raw.split()[0] if raw.split() else ""
if not token or token.lower() == "none" or token not in self._REACTION_EMOJIS:
self._log_llm("pick_reaction", elapsed, True, message_text[:200], "NONE")
return None
self._log_llm("pick_reaction", elapsed, True, message_text[:200], token)
logger.debug("Picked reaction %s for: %s", token, message_text[:80])
return token
except Exception as e:
elapsed = int((time.monotonic() - t0) * 1000)
logger.error("Reaction pick error: %s", e)
self._log_llm("pick_reaction", elapsed, False, message_text[:200], error=str(e))
return None
async def check_reply_relevance(
self, recent_messages: list[str], memory_context: str = "",
) -> bool:
"""Check if the bot would naturally want to jump into a conversation.
Returns True if the conversation is something worth replying to.
"""
prompt = (
"You're a regular member of a Discord gaming server. You're reading chat and deciding "
"whether you'd naturally want to jump in and say something.\n\n"
"Say YES if:\n"
"- Someone said something you'd have a strong reaction to\n"
"- You know something relevant about these people (see memory context)\n"
"- Someone is wrong or has a hot take you'd want to respond to\n"
"- The conversation is funny or interesting enough to comment on\n"
"- Someone mentioned something you have an opinion on\n\n"
"Say NO if:\n"
"- It's mundane/boring small talk\n"
"- You'd have nothing interesting to add\n"
"- People are just chatting normally and don't need interruption\n\n"
"Reply with EXACTLY one word: YES or NO."
)
convo_text = "\n".join(recent_messages[-5:])
user_content = ""
if memory_context:
user_content += f"{memory_context}\n\n"
user_content += f"Recent chat:\n{convo_text}"
t0 = time.monotonic()
async with self._semaphore:
try:
temp_kwargs = {"temperature": 0.3} if self._supports_temperature else {}
response = await self._client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": prompt},
{"role": "user", "content": user_content[:1000]},
],
**temp_kwargs,
max_completion_tokens=16,
)
elapsed = int((time.monotonic() - t0) * 1000)
content = (response.choices[0].message.content or "").strip().lower()
is_relevant = "yes" in content
self._log_llm(
"check_relevance", elapsed, True,
user_content[:300], content,
)
logger.debug("Relevance check: %s", content)
return is_relevant
except Exception as e:
elapsed = int((time.monotonic() - t0) * 1000)
logger.error("Relevance check error: %s", e)
self._log_llm("check_relevance", elapsed, False, user_content[:300], error=str(e))
return False
async def extract_memories(
self,
conversation: list[dict[str, str]],
@@ -859,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,