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>
This commit is contained in:
2026-02-21 17:02:59 -05:00
parent e41845de02
commit fee3e3e1bd
5 changed files with 150 additions and 4 deletions

View File

@@ -358,8 +358,25 @@ class CommandsCog(commands.Cog):
await interaction.response.defer(ephemeral=True) 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) 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( embed = discord.Embed(
title="BCS Test Analysis", color=discord.Color.blue() title="BCS Test Analysis", color=discord.Color.blue()
@@ -389,6 +406,14 @@ class CommandsCog(commands.Cog):
value=parsed["reasoning"][:1024] or "n/a", value=parsed["reasoning"][:1024] or "n/a",
inline=False, 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: else:
embed.add_field( embed.add_field(
name="Parsing", value="Failed to parse response", inline=False 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] = {} self._channel_history: dict[int, deque] = {}
# Track which user IDs have unsaved in-memory changes # Track which user IDs have unsaved in-memory changes
self._dirty_users: set[int] = set() 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): async def cog_load(self):
self._flush_states.start() self._flush_states.start()
@@ -79,11 +81,16 @@ class SentimentCog(commands.Cog):
if not self.bot.drama_tracker.can_analyze(message.author.id, cooldown): if not self.bot.drama_tracker.can_analyze(message.author.id, cooldown):
return 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 # Analyze the message
context = self._get_context(message) context = self._get_context(message)
user_notes = self.bot.drama_tracker.get_user_notes(message.author.id) user_notes = self.bot.drama_tracker.get_user_notes(message.author.id)
result = await self.bot.llm.analyze_message( 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: if result is None:
@@ -137,6 +144,11 @@ class SentimentCog(commands.Cog):
if off_topic: if off_topic:
await self._handle_topic_drift(message, topic_category, topic_reasoning, db_message_id) 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 / intoxication detection
coherence_score = result.get("coherence_score", 0.85) coherence_score = result.get("coherence_score", 0.85)
coherence_flag = result.get("coherence_flag", "normal") 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)) 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): def _store_context(self, message: discord.Message):
ch_id = message.channel.id ch_id = message.channel.id
if ch_id not in self._channel_history: if ch_id not in self._channel_history:

View File

@@ -19,6 +19,12 @@ sentiment:
rolling_window_minutes: 15 # Time window for tracking rolling_window_minutes: 15 # Time window for tracking
cooldown_between_analyses: 2 # Seconds between analyzing same user's messages 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: topic_drift:
enabled: true enabled: true
remind_cooldown_minutes: 10 # Don't remind same user more than once per this window 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_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_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." 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: coherence:
enabled: true 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 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 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. - 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). 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. 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. Use the report_analysis tool to report your analysis of the TARGET MESSAGE only.

View File

@@ -34,6 +34,7 @@ ANALYSIS_TOOL = {
"instigating", "instigating",
"hostile", "hostile",
"manipulative", "manipulative",
"sexual_vulgar",
"none", "none",
], ],
}, },
@@ -84,6 +85,10 @@ ANALYSIS_TOOL = {
"type": ["string", "null"], "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.",
}, },
"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"], "required": ["toxicity_score", "categories", "reasoning", "off_topic", "topic_category", "topic_reasoning", "coherence_score", "coherence_flag"],
}, },
@@ -106,11 +111,14 @@ class LLMClient:
await self._client.close() await self._client.close()
async def analyze_message( 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: ) -> dict | None:
user_content = f"=== CONTEXT (other users' recent messages, for background only) ===\n{context}\n\n" user_content = f"=== CONTEXT (other users' recent messages, for background only) ===\n{context}\n\n"
if user_notes: if user_notes:
user_content += f"=== NOTES ABOUT THIS USER (from prior analysis) ===\n{user_notes}\n\n" 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}" user_content += f"=== TARGET MESSAGE (analyze THIS message only) ===\n{message}"
async with self._semaphore: async with self._semaphore:
@@ -165,6 +173,7 @@ class LLMClient:
result.setdefault("coherence_flag", "normal") result.setdefault("coherence_flag", "normal")
result.setdefault("note_update", None) result.setdefault("note_update", None)
result.setdefault("detected_game", None)
return result return result
@@ -288,11 +297,16 @@ class LLMClient:
logger.error("LLM image analysis error: %s", e) logger.error("LLM image analysis error: %s", e)
return None return None
async def raw_analyze(self, message: str, context: str = "", user_notes: str = "") -> tuple[str, dict | 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).""" """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" user_content = f"=== CONTEXT (other users' recent messages, for background only) ===\n{context}\n\n"
if user_notes: if user_notes:
user_content += f"=== NOTES ABOUT THIS USER (from prior analysis) ===\n{user_notes}\n\n" 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}" user_content += f"=== TARGET MESSAGE (analyze THIS message only) ===\n{message}"
async with self._semaphore: async with self._semaphore: