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:
@@ -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.
|
||||
@@ -34,6 +34,7 @@ ANALYSIS_TOOL = {
|
||||
"instigating",
|
||||
"hostile",
|
||||
"manipulative",
|
||||
"sexual_vulgar",
|
||||
"none",
|
||||
],
|
||||
},
|
||||
@@ -84,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"],
|
||||
},
|
||||
@@ -106,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:
|
||||
@@ -165,6 +173,7 @@ class LLMClient:
|
||||
result.setdefault("coherence_flag", "normal")
|
||||
|
||||
result.setdefault("note_update", None)
|
||||
result.setdefault("detected_game", None)
|
||||
|
||||
return result
|
||||
|
||||
@@ -288,11 +297,16 @@ class LLMClient:
|
||||
logger.error("LLM image analysis error: %s", e)
|
||||
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)."""
|
||||
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