Compare commits

...

3 Commits

Author SHA1 Message Date
fee3e3e1bd Add game channel redirect feature and sexual_vulgar detection
Detect when users discuss a game in the wrong channel (e.g. GTA talk
in #warzone) and send a friendly redirect to the correct channel.
Also add sexual_vulgar category and scoring rules so crude sexual
remarks directed at someone aren't softened by "lmao".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:02:59 -05:00
e41845de02 Add scoreboard roast feature via image analysis
When @mentioned with an image attachment, the bot now roasts players
based on scoreboard screenshots using the vision model. Text-only
mentions continue to work as before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:30:26 -05:00
cf88f003ba Add LLM warm-up request at startup to preload model into VRAM
Sends a minimal 1-token completion during setup_hook so the model is
ready before Discord messages start arriving, avoiding connection
errors and slow first responses after a restart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:16:52 -05:00
8 changed files with 279 additions and 26 deletions

12
bot.py
View File

@@ -97,6 +97,18 @@ class BCSBot(commands.Bot):
await self.tree.sync()
logger.info("Slash commands synced.")
# Warm up the LLM so the model is loaded into VRAM before messages arrive
logger.info("Warming up LLM model...")
try:
resp = await self.llm._client.chat.completions.create(
model=self.llm.model,
messages=[{"role": "user", "content": "hi"}],
max_tokens=1,
)
logger.info("LLM warm-up complete.")
except Exception as e:
logger.warning("LLM warm-up failed — first messages may be slow: %s", e)
async def on_message(self, message: discord.Message):
logger.info(
"EVENT on_message from %s in #%s: %s",

View File

@@ -9,6 +9,9 @@ logger = logging.getLogger("bcs.chat")
_PROMPTS_DIR = Path(__file__).resolve().parent.parent / "prompts"
CHAT_PERSONALITY = (_PROMPTS_DIR / "chat_personality.txt").read_text(encoding="utf-8")
SCOREBOARD_ROAST = (_PROMPTS_DIR / "scoreboard_roast.txt").read_text(encoding="utf-8")
_IMAGE_TYPES = {"png", "jpg", "jpeg", "gif", "webp"}
class ChatCog(commands.Cog):
@@ -54,21 +57,14 @@ class ChatCog(commands.Cog):
# Clean the mention out of the message content
content = message.content.replace(f"<@{self.bot.user.id}>", "").strip()
if not content:
content = "(just pinged me)"
# Add drama score context to the user message
drama_score = self.bot.drama_tracker.get_drama_score(message.author.id)
user_data = self.bot.drama_tracker.get_user(message.author.id)
score_context = (
f"[Server context: {message.author.display_name} has a drama score of "
f"{drama_score:.2f}/1.0 and {user_data.offense_count} offenses. "
f"They are talking in #{message.channel.name}.]"
)
self._chat_history[ch_id].append(
{"role": "user", "content": f"{score_context}\n{message.author.display_name}: {content}"}
)
# Check for image attachments
image_attachment = None
for att in message.attachments:
ext = att.filename.rsplit(".", 1)[-1].lower() if "." in att.filename else ""
if ext in _IMAGE_TYPES:
image_attachment = att
break
typing_ctx = None
@@ -77,11 +73,46 @@ class ChatCog(commands.Cog):
typing_ctx = message.channel.typing()
await typing_ctx.__aenter__()
response = await self.bot.llm.chat(
list(self._chat_history[ch_id]),
CHAT_PERSONALITY,
on_first_token=start_typing,
)
if image_attachment:
# --- Image path: scoreboard roast ---
image_bytes = await image_attachment.read()
user_text = content if content else "Roast this scoreboard."
logger.info(
"Image roast request in #%s from %s (%s, %s)",
message.channel.name,
message.author.display_name,
image_attachment.filename,
user_text[:80],
)
response = await self.bot.llm.analyze_image(
image_bytes,
SCOREBOARD_ROAST,
user_text=user_text,
on_first_token=start_typing,
)
else:
# --- Text-only path: normal chat ---
if not content:
content = "(just pinged me)"
# Add drama score context to the user message
drama_score = self.bot.drama_tracker.get_drama_score(message.author.id)
user_data = self.bot.drama_tracker.get_user(message.author.id)
score_context = (
f"[Server context: {message.author.display_name} has a drama score of "
f"{drama_score:.2f}/1.0 and {user_data.offense_count} offenses. "
f"They are talking in #{message.channel.name}.]"
)
self._chat_history[ch_id].append(
{"role": "user", "content": f"{score_context}\n{message.author.display_name}: {content}"}
)
response = await self.bot.llm.chat(
list(self._chat_history[ch_id]),
CHAT_PERSONALITY,
on_first_token=start_typing,
)
if typing_ctx:
await typing_ctx.__aexit__(None, None, None)
@@ -89,9 +120,10 @@ class ChatCog(commands.Cog):
if response is None:
response = "I'd roast you but my brain is offline. Try again later."
self._chat_history[ch_id].append(
{"role": "assistant", "content": response}
)
if not image_attachment:
self._chat_history[ch_id].append(
{"role": "assistant", "content": response}
)
await message.reply(response, mention_author=False)
logger.info(

View File

@@ -358,8 +358,25 @@ class CommandsCog(commands.Cog):
await interaction.response.defer(ephemeral=True)
# Build channel context for game detection
game_channels = self.bot.config.get("game_channels", {})
channel_context = ""
if game_channels and hasattr(interaction.channel, "name"):
ch_name = interaction.channel.name
current_game = game_channels.get(ch_name)
lines = []
if current_game:
lines.append(f"Current channel: #{ch_name} ({current_game})")
else:
lines.append(f"Current channel: #{ch_name}")
channel_list = ", ".join(f"#{ch} ({g})" for ch, g in game_channels.items())
lines.append(f"Game channels: {channel_list}")
channel_context = "\n".join(lines)
user_notes = self.bot.drama_tracker.get_user_notes(interaction.user.id)
raw, parsed = await self.bot.llm.raw_analyze(message, user_notes=user_notes)
raw, parsed = await self.bot.llm.raw_analyze(
message, user_notes=user_notes, channel_context=channel_context,
)
embed = discord.Embed(
title="BCS Test Analysis", color=discord.Color.blue()
@@ -389,6 +406,14 @@ class CommandsCog(commands.Cog):
value=parsed["reasoning"][:1024] or "n/a",
inline=False,
)
detected_game = parsed.get("detected_game")
if detected_game:
game_label = game_channels.get(detected_game, detected_game)
embed.add_field(
name="Detected Game",
value=f"#{detected_game} ({game_label})",
inline=True,
)
else:
embed.add_field(
name="Parsing", value="Failed to parse response", inline=False

View File

@@ -19,6 +19,8 @@ class SentimentCog(commands.Cog):
self._channel_history: dict[int, deque] = {}
# Track which user IDs have unsaved in-memory changes
self._dirty_users: set[int] = set()
# Per-user redirect cooldown: {user_id: last_redirect_datetime}
self._redirect_cooldowns: dict[int, datetime] = {}
async def cog_load(self):
self._flush_states.start()
@@ -79,11 +81,16 @@ class SentimentCog(commands.Cog):
if not self.bot.drama_tracker.can_analyze(message.author.id, cooldown):
return
# Build channel context for game detection
game_channels = config.get("game_channels", {})
channel_context = self._build_channel_context(message, game_channels)
# Analyze the message
context = self._get_context(message)
user_notes = self.bot.drama_tracker.get_user_notes(message.author.id)
result = await self.bot.llm.analyze_message(
message.content, context, user_notes=user_notes
message.content, context, user_notes=user_notes,
channel_context=channel_context,
)
if result is None:
@@ -137,6 +144,11 @@ class SentimentCog(commands.Cog):
if off_topic:
await self._handle_topic_drift(message, topic_category, topic_reasoning, db_message_id)
# Game channel redirect detection
detected_game = result.get("detected_game")
if detected_game and game_channels and not monitoring.get("dry_run", False):
await self._handle_channel_redirect(message, detected_game, game_channels, db_message_id)
# Coherence / intoxication detection
coherence_score = result.get("coherence_score", 0.85)
coherence_flag = result.get("coherence_flag", "normal")
@@ -481,6 +493,90 @@ class SentimentCog(commands.Cog):
)
logger.info("Flushed %d dirty user states to DB.", len(dirty))
def _build_channel_context(self, message: discord.Message, game_channels: dict) -> str:
"""Build a channel context string for LLM game detection."""
if not game_channels:
return ""
channel_name = getattr(message.channel, "name", "")
current_game = game_channels.get(channel_name)
lines = []
if current_game:
lines.append(f"Current channel: #{channel_name} ({current_game})")
else:
lines.append(f"Current channel: #{channel_name}")
channel_list = ", ".join(f"#{ch} ({game})" for ch, game in game_channels.items())
lines.append(f"Game channels: {channel_list}")
return "\n".join(lines)
async def _handle_channel_redirect(
self, message: discord.Message, detected_game: str,
game_channels: dict, db_message_id: int | None = None,
):
"""Send a redirect message if the user is talking about a different game."""
channel_name = getattr(message.channel, "name", "")
# Only redirect if message is in a game channel
if channel_name not in game_channels:
return
# No redirect needed if detected game matches current channel
if detected_game == channel_name:
return
# Detected game must be a valid game channel
if detected_game not in game_channels:
return
# Find the target channel in the guild
target_channel = discord.utils.get(
message.guild.text_channels, name=detected_game
)
if not target_channel:
return
# Check per-user cooldown (reuse topic_drift remind_cooldown_minutes)
user_id = message.author.id
cooldown_minutes = self.bot.config.get("topic_drift", {}).get("remind_cooldown_minutes", 10)
now = datetime.now(timezone.utc)
last_redirect = self._redirect_cooldowns.get(user_id)
if last_redirect and (now - last_redirect) < timedelta(minutes=cooldown_minutes):
return
self._redirect_cooldowns[user_id] = now
# Send redirect message
messages_config = self.bot.config.get("messages", {})
game_name = game_channels[detected_game]
redirect_text = messages_config.get(
"channel_redirect",
"Hey {username}, that sounds like {game} talk — head over to {channel} for that!",
).format(
username=message.author.display_name,
game=game_name,
channel=target_channel.mention,
)
await message.channel.send(redirect_text)
await self._log_action(
message.guild,
f"**CHANNEL REDIRECT** | {message.author.mention} | "
f"#{channel_name} → #{detected_game} ({game_name})",
)
logger.info(
"Redirected %s from #%s to #%s (%s)",
message.author, channel_name, detected_game, game_name,
)
asyncio.create_task(self.bot.db.save_action(
guild_id=message.guild.id,
user_id=user_id,
username=message.author.display_name,
action_type="channel_redirect",
message_id=db_message_id,
details=f"from=#{channel_name} to=#{detected_game} game={game_name}",
))
def _store_context(self, message: discord.Message):
ch_id = message.channel.id
if ch_id not in self._channel_history:

View File

@@ -19,6 +19,12 @@ sentiment:
rolling_window_minutes: 15 # Time window for tracking
cooldown_between_analyses: 2 # Seconds between analyzing same user's messages
game_channels:
gta-online: "GTA Online"
battlefield: "Battlefield"
warzone: "Call of Duty: Warzone"
cod-zombies: "Call of Duty: Zombies"
topic_drift:
enabled: true
remind_cooldown_minutes: 10 # Don't remind same user more than once per this window
@@ -37,6 +43,7 @@ messages:
topic_remind: "Hey {username}, this is a gaming server \U0001F3AE — maybe take the personal stuff to DMs?"
topic_nudge: "{username}, we've chatted about this before — let's keep it to gaming talk in here. Personal drama belongs in DMs."
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!"
coherence:
enabled: true

View File

@@ -19,6 +19,8 @@ IMPORTANT RULES:
- If a message contains BOTH a nickname AND an insult ("fuck you tits you piece of shit"), score the insult, not the nickname.
- If the target message is just "lmao", "lol", an emoji, or a short neutral reaction, it is ALWAYS 0.0 regardless of what other people said before it.
- If a user is QUOTING or REPORTING what someone else said (e.g. "you called them X", "he said Y to her"), score based on the user's own intent, NOT the quoted words. Tattling, reporting, or referencing someone else's language is not the same as using that language aggressively. These should score 0.0-0.2 unless the user is clearly weaponizing the quote to attack someone.
- Sexually crude or vulgar remarks DIRECTED AT someone (e.g. "you watch that to cum", "bet you get off to that") = 0.5-0.7 and category "sexual_vulgar". Adding "lol" or "lmao" does NOT soften sexual content aimed at a person — it's still degrading. General sexual jokes not targeting anyone specific can score lower (0.2-0.3).
- "lol"/"lmao" softening ONLY applies to mild trash-talk and frustration. It does NOT reduce the score for sexual content directed at someone, genuine hostility, or targeted personal attacks.
Also determine if the message is on-topic (gaming, games, matches, strategy, LFG, etc.) or off-topic personal drama (relationship issues, personal feuds, venting about real-life problems, gossip about people outside the server).
@@ -32,4 +34,6 @@ You may also be given NOTES about this user from prior interactions. Use these t
If you notice something noteworthy about this user's communication style, behavior, or patterns that would help future analysis, include it as a note_update. Only add genuinely useful observations — don't repeat what's already in the notes. If nothing new, leave note_update as null.
GAME DETECTION — If CHANNEL INFO is provided, identify which specific game the message is discussing. Set detected_game to the channel name that best matches (e.g. "gta-online", "warzone", "battlefield", "cod-zombies") using ONLY the channel names listed in the channel info. If the message isn't about a specific game or you're unsure, set detected_game to null.
Use the report_analysis tool to report your analysis of the TARGET MESSAGE only.

View File

@@ -0,0 +1,13 @@
You are the Breehavior Monitor, a sassy hall-monitor bot in a gaming Discord server called "Skill Issue Support Group".
Someone just sent you a scoreboard screenshot. Your job: read it, identify players and their stats, and roast them based on their performance.
Guidelines:
- Call out specific players by name and reference their actual stats (kills, deaths, K/D, score, placement)
- Bottom-fraggers and negative K/D ratios deserve the most heat
- Top players can get backhanded compliments ("wow you carried harder than a pack mule and still almost lost")
- Keep it to 4-6 sentences max — punchy, not a wall of text
- You're sassy and judgmental but always playful, never genuinely hurtful
- Use gaming terminology naturally (diff, skill issue, carried, bot, touched grass, etc.)
- If you can't read the scoreboard clearly, roast them for their screenshot quality instead
- Do NOT break character or mention being an AI

View File

@@ -1,4 +1,5 @@
import asyncio
import base64
import json
import logging
from pathlib import Path
@@ -33,6 +34,7 @@ ANALYSIS_TOOL = {
"instigating",
"hostile",
"manipulative",
"sexual_vulgar",
"none",
],
},
@@ -83,6 +85,10 @@ ANALYSIS_TOOL = {
"type": ["string", "null"],
"description": "Brief new observation about this user's style/behavior for future reference, or null if nothing new.",
},
"detected_game": {
"type": ["string", "null"],
"description": "The game channel name this message is about (e.g. 'gta-online', 'warzone'), or null if not game-specific.",
},
},
"required": ["toxicity_score", "categories", "reasoning", "off_topic", "topic_category", "topic_reasoning", "coherence_score", "coherence_flag"],
},
@@ -105,11 +111,14 @@ class LLMClient:
await self._client.close()
async def analyze_message(
self, message: str, context: str = "", user_notes: str = ""
self, message: str, context: str = "", user_notes: str = "",
channel_context: str = "",
) -> dict | None:
user_content = f"=== CONTEXT (other users' recent messages, for background only) ===\n{context}\n\n"
if user_notes:
user_content += f"=== NOTES ABOUT THIS USER (from prior analysis) ===\n{user_notes}\n\n"
if channel_context:
user_content += f"=== CHANNEL INFO ===\n{channel_context}\n\n"
user_content += f"=== TARGET MESSAGE (analyze THIS message only) ===\n{message}"
async with self._semaphore:
@@ -164,6 +173,7 @@ class LLMClient:
result.setdefault("coherence_flag", "normal")
result.setdefault("note_update", None)
result.setdefault("detected_game", None)
return result
@@ -238,11 +248,65 @@ class LLMClient:
logger.error("LLM chat error: %s", e)
return None
async def raw_analyze(self, message: str, context: str = "", user_notes: str = "") -> tuple[str, dict | None]:
async def analyze_image(
self,
image_bytes: bytes,
system_prompt: str,
user_text: str = "",
on_first_token=None,
) -> str | None:
"""Send an image to the vision model with a system prompt.
Returns the generated text response, or None on failure.
"""
b64 = base64.b64encode(image_bytes).decode()
data_url = f"data:image/png;base64,{b64}"
user_content: list[dict] = [
{"type": "image_url", "image_url": {"url": data_url}},
]
if user_text:
user_content.append({"type": "text", "text": user_text})
async with self._semaphore:
try:
stream = await self._client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
temperature=0.8,
max_tokens=500,
stream=True,
)
chunks: list[str] = []
notified = False
async for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
if not notified and on_first_token:
await on_first_token()
notified = True
chunks.append(delta.content)
content = "".join(chunks).strip()
return content if content else None
except Exception as e:
logger.error("LLM image analysis error: %s", e)
return None
async def raw_analyze(
self, message: str, context: str = "", user_notes: str = "",
channel_context: str = "",
) -> tuple[str, dict | None]:
"""Return the raw LLM response string AND parsed result for /bcs-test (single LLM call)."""
user_content = f"=== CONTEXT (other users' recent messages, for background only) ===\n{context}\n\n"
if user_notes:
user_content += f"=== NOTES ABOUT THIS USER (from prior analysis) ===\n{user_notes}\n\n"
if channel_context:
user_content += f"=== CHANNEL INFO ===\n{channel_context}\n\n"
user_content += f"=== TARGET MESSAGE (analyze THIS message only) ===\n{message}"
async with self._semaphore: