Compare commits
3 Commits
b410200146
...
fee3e3e1bd
| Author | SHA1 | Date | |
|---|---|---|---|
| fee3e3e1bd | |||
| e41845de02 | |||
| cf88f003ba |
12
bot.py
12
bot.py
@@ -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",
|
||||
|
||||
76
cogs/chat.py
76
cogs/chat.py
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
13
prompts/scoreboard_roast.txt
Normal file
13
prompts/scoreboard_roast.txt
Normal 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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user