diff --git a/bot.py b/bot.py index de639f2..7049a0d 100644 --- a/bot.py +++ b/bot.py @@ -78,6 +78,10 @@ class BCSBot(commands.Bot): llm_heavy_model = os.getenv("LLM_ESCALATION_MODEL", llm_model) self.llm_heavy = LLMClient(llm_base_url, llm_heavy_model, llm_api_key, db=self.db) + # Active mode (server-wide) + modes_config = config.get("modes", {}) + self.current_mode = modes_config.get("default_mode", "default") + # Drama tracker sentiment = config.get("sentiment", {}) timeouts = config.get("timeouts", {}) @@ -87,6 +91,11 @@ class BCSBot(commands.Bot): offense_reset_minutes=timeouts.get("offense_reset_minutes", 120), ) + def get_mode_config(self) -> dict: + """Return the config dict for the currently active mode.""" + modes = self.config.get("modes", {}) + return modes.get(self.current_mode, modes.get("default", {})) + async def setup_hook(self): # Initialize database and hydrate DramaTracker db_ok = await self.db.init() diff --git a/cogs/chat.py b/cogs/chat.py index 8c4ea1c..2f03f48 100644 --- a/cogs/chat.py +++ b/cogs/chat.py @@ -1,5 +1,6 @@ import asyncio import logging +import random from collections import deque from pathlib import Path @@ -9,17 +10,33 @@ from discord.ext import commands 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"} +# Cache loaded prompt files so we don't re-read on every message +_prompt_cache: dict[str, str] = {} + + +def _load_prompt(filename: str) -> str: + if filename not in _prompt_cache: + _prompt_cache[filename] = (_PROMPTS_DIR / filename).read_text(encoding="utf-8") + return _prompt_cache[filename] + class ChatCog(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot # Per-channel conversation history for the bot: {channel_id: deque of {role, content}} self._chat_history: dict[int, deque] = {} + # Counter of messages seen since last proactive reply (per channel) + self._messages_since_reply: dict[int, int] = {} + + def _get_active_prompt(self) -> str: + """Load the chat prompt for the current mode.""" + mode_config = self.bot.get_mode_config() + prompt_file = mode_config.get("prompt_file", "chat_personality.txt") + return _load_prompt(prompt_file) @commands.Cog.listener() async def on_message(self, message: discord.Message): @@ -30,6 +47,7 @@ class ChatCog(commands.Cog): return should_reply = False + is_proactive = False # Check if bot is @mentioned if self.bot.user in message.mentions: @@ -48,6 +66,24 @@ class ChatCog(commands.Cog): except discord.HTTPException: pass + # Proactive reply check (only if not already replying to a mention/reply) + if not should_reply: + mode_config = self.bot.get_mode_config() + if mode_config.get("proactive_replies", False): + 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 + if not should_reply: return @@ -94,7 +130,7 @@ class ChatCog(commands.Cog): else: # --- Text-only path: normal chat --- if not content: - content = "(just pinged me)" + content = "(just pinged me)" if not is_proactive else message.content # Add drama score context only when noteworthy drama_score = self.bot.drama_tracker.get_drama_score(message.author.id) @@ -110,9 +146,11 @@ class ChatCog(commands.Cog): {"role": "user", "content": f"{score_context}\n{message.author.display_name}: {content}"} ) + active_prompt = self._get_active_prompt() + response = await self.bot.llm.chat( list(self._chat_history[ch_id]), - CHAT_PERSONALITY, + active_prompt, on_first_token=start_typing, ) @@ -137,6 +175,10 @@ class ChatCog(commands.Cog): {"role": "assistant", "content": response} ) + # Reset proactive cooldown counter for this channel + if is_proactive: + self._messages_since_reply[ch_id] = 0 + # Wait for any pending sentiment analysis to finish first so # warnings/mutes appear before the chat reply sentiment_cog = self.bot.get_cog("SentimentCog") @@ -149,9 +191,15 @@ class ChatCog(commands.Cog): except (asyncio.TimeoutError, asyncio.CancelledError): pass - await message.reply(response, mention_author=False) + if is_proactive: + await message.channel.send(response) + else: + await message.reply(response, mention_author=False) + + reply_type = "proactive" if is_proactive else "chat" logger.info( - "Chat reply in #%s to %s: %s", + "%s reply in #%s to %s: %s", + reply_type.capitalize(), message.channel.name, message.author.display_name, response[:100], diff --git a/cogs/commands.py b/cogs/commands.py index 6a514ef..ca6efdf 100644 --- a/cogs/commands.py +++ b/cogs/commands.py @@ -109,6 +109,24 @@ class CommandsCog(commands.Cog): title="BCS Status", color=discord.Color.green() if enabled else discord.Color.greyple(), ) + mode_config = self.bot.get_mode_config() + mode_label = mode_config.get("label", self.bot.current_mode) + moderation_level = mode_config.get("moderation", "full") + + # Show effective thresholds (relaxed if applicable) + if moderation_level == "relaxed" and "relaxed_thresholds" in mode_config: + rt = mode_config["relaxed_thresholds"] + eff_warn = rt.get("warning_threshold", 0.80) + eff_mute = rt.get("mute_threshold", 0.85) + else: + eff_warn = sentiment.get("warning_threshold", 0.6) + eff_mute = sentiment.get("mute_threshold", 0.75) + + embed.add_field( + name="Mode", + value=f"{mode_label} ({moderation_level})", + inline=True, + ) embed.add_field( name="Monitoring", value="Active" if enabled else "Disabled", @@ -117,12 +135,12 @@ class CommandsCog(commands.Cog): embed.add_field(name="Channels", value=ch_text, inline=True) embed.add_field( name="Warning Threshold", - value=str(sentiment.get("warning_threshold", 0.6)), + value=str(eff_warn), inline=True, ) embed.add_field( name="Mute Threshold", - value=str(sentiment.get("mute_threshold", 0.75)), + value=str(eff_mute), inline=True, ) embed.add_field( @@ -503,6 +521,86 @@ class CommandsCog(commands.Cog): f"Notes cleared for {user.display_name}.", ephemeral=True ) + @app_commands.command( + name="bcs-mode", + description="Switch the bot's personality mode.", + ) + @app_commands.describe(mode="The mode to switch to") + async def bcs_mode( + self, interaction: discord.Interaction, mode: str | None = None, + ): + modes_config = self.bot.config.get("modes", {}) + # Collect valid mode names (skip non-dict keys like default_mode, proactive_cooldown_messages) + valid_modes = [k for k, v in modes_config.items() if isinstance(v, dict)] + + if mode is None: + # Show current mode and available modes + current = self.bot.current_mode + current_config = self.bot.get_mode_config() + lines = [f"**Current mode:** {current_config.get('label', current)}"] + lines.append(f"*{current_config.get('description', '')}*\n") + lines.append("**Available modes:**") + for name in valid_modes: + mc = modes_config[name] + indicator = " (active)" if name == current else "" + lines.append(f"- `{name}` — {mc.get('label', name)}: {mc.get('description', '')}{indicator}") + await interaction.response.send_message("\n".join(lines), ephemeral=True) + return + + mode = mode.lower() + if mode not in valid_modes: + await interaction.response.send_message( + f"Unknown mode `{mode}`. Available: {', '.join(f'`{m}`' for m in valid_modes)}", + ephemeral=True, + ) + return + + old_mode = self.bot.current_mode + self.bot.current_mode = mode + new_config = self.bot.get_mode_config() + + # Update bot status to reflect the mode + status_text = new_config.get("description", "Monitoring vibes...") + await self.bot.change_presence( + activity=discord.Activity( + type=discord.ActivityType.watching, name=status_text + ) + ) + + await interaction.response.send_message( + f"Mode switched: **{modes_config.get(old_mode, {}).get('label', old_mode)}** " + f"-> **{new_config.get('label', mode)}**\n" + f"*{new_config.get('description', '')}*" + ) + + # Log mode change + log_channel = discord.utils.get(interaction.guild.text_channels, name="bcs-log") + if log_channel: + try: + await log_channel.send( + f"**MODE CHANGE** | {interaction.user.mention} switched mode: " + f"**{old_mode}** -> **{mode}**" + ) + except discord.HTTPException: + pass + + logger.info( + "Mode changed from %s to %s by %s", + old_mode, mode, interaction.user.display_name, + ) + + @bcs_mode.autocomplete("mode") + async def _mode_autocomplete( + self, interaction: discord.Interaction, current: str, + ) -> list[app_commands.Choice[str]]: + modes_config = self.bot.config.get("modes", {}) + valid_modes = [k for k, v in modes_config.items() if isinstance(v, dict)] + return [ + app_commands.Choice(name=modes_config[m].get("label", m), value=m) + for m in valid_modes + if current.lower() in m.lower() + ][:25] + @staticmethod def _score_bar(score: float) -> str: filled = round(score * 10) diff --git a/cogs/sentiment.py b/cogs/sentiment.py index 20f45a6..4a25711 100644 --- a/cogs/sentiment.py +++ b/cogs/sentiment.py @@ -262,14 +262,23 @@ class SentimentCog(commands.Cog): if dry_run: return - # Check thresholds — both rolling average AND single-message spikes - warning_threshold = sentiment_config.get("warning_threshold", 0.6) - base_mute_threshold = sentiment_config.get("mute_threshold", 0.75) + # Check thresholds — use relaxed thresholds if the active mode says so + mode_config = self.bot.get_mode_config() + moderation_level = mode_config.get("moderation", "full") + if moderation_level == "relaxed" and "relaxed_thresholds" in mode_config: + rt = mode_config["relaxed_thresholds"] + warning_threshold = rt.get("warning_threshold", 0.80) + base_mute_threshold = rt.get("mute_threshold", 0.85) + spike_warn = rt.get("spike_warning_threshold", 0.70) + spike_mute = rt.get("spike_mute_threshold", 0.85) + else: + warning_threshold = sentiment_config.get("warning_threshold", 0.6) + base_mute_threshold = sentiment_config.get("mute_threshold", 0.75) + spike_warn = sentiment_config.get("spike_warning_threshold", 0.5) + spike_mute = sentiment_config.get("spike_mute_threshold", 0.8) mute_threshold = self.bot.drama_tracker.get_mute_threshold( message.author.id, base_mute_threshold ) - spike_warn = sentiment_config.get("spike_warning_threshold", 0.5) - spike_mute = sentiment_config.get("spike_mute_threshold", 0.8) # Mute: rolling average OR single message spike if drama_score >= mute_threshold or score >= spike_mute: diff --git a/config.yaml b/config.yaml index db4d693..79e1ce7 100644 --- a/config.yaml +++ b/config.yaml @@ -46,6 +46,44 @@ messages: 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!" +modes: + default_mode: default + proactive_cooldown_messages: 5 # Minimum messages between proactive replies + + default: + label: "Default" + description: "Hall-monitor moderation mode" + prompt_file: "chat_personality.txt" + proactive_replies: false + reply_chance: 0.0 + moderation: full + + chatty: + label: "Chatty" + description: "Friendly chat participant" + prompt_file: "chat_chatty.txt" + proactive_replies: true + reply_chance: 0.10 + moderation: relaxed + relaxed_thresholds: + warning_threshold: 0.80 + mute_threshold: 0.85 + spike_warning_threshold: 0.70 + spike_mute_threshold: 0.85 + + roast: + label: "Roast" + description: "Savage roast mode" + prompt_file: "chat_roast.txt" + proactive_replies: true + reply_chance: 0.10 + moderation: relaxed + relaxed_thresholds: + warning_threshold: 0.85 + mute_threshold: 0.90 + spike_warning_threshold: 0.75 + spike_mute_threshold: 0.90 + coherence: enabled: true drop_threshold: 0.3 # How far below baseline triggers alert diff --git a/prompts/chat_chatty.txt b/prompts/chat_chatty.txt new file mode 100644 index 0000000..6722c95 --- /dev/null +++ b/prompts/chat_chatty.txt @@ -0,0 +1,24 @@ +You are hanging out in a gaming Discord server called "Skill Issue Support Group". You're a regular member of the crew — friendly, funny, and genuinely engaged in conversations. + +Your personality: +- You're like a chill friend who's always down to chat about whatever +- You use humor naturally — not forced, not trying too hard +- You're into gaming and nerd culture but can talk about anything +- You match the energy of the conversation — hype when people are hype, sympathetic when someone's having a bad day +- You have opinions and share them, but you're not argumentative +- You speak casually — short messages, like real Discord chat. 1-3 sentences max. +- You use gaming slang and internet humor naturally +- You remember you're technically the server's monitor bot but you're off-duty right now and just vibing + +Examples of your vibe: +- "lmao that play was actually disgusting, clip that" +- "nah you're cooked for that one" +- "honestly same, I've been on a losing streak all week" +- "wait that's actually a good take, I never thought about it like that" + +Do NOT: +- Break character or talk about being an AI/LLM +- Write more than 3 sentences +- Use hashtags or excessive emoji +- Be a pushover — you have personality and opinions +- Mention drama scores or monitoring unless someone brings it up first \ No newline at end of file diff --git a/prompts/chat_roast.txt b/prompts/chat_roast.txt new file mode 100644 index 0000000..d0603c1 --- /dev/null +++ b/prompts/chat_roast.txt @@ -0,0 +1,24 @@ +You are the roast master in a gaming Discord server called "Skill Issue Support Group". You exist to absolutely flame everyone in the chat. No one is safe. + +Your personality: +- You are ruthlessly funny — every message is an opportunity to roast someone +- You target what people are saying, their gaming skills, their takes, their life choices +- You're creative with insults — never generic, always personalized to what's happening in chat +- You punch in every direction equally — no favorites, no mercy +- Your roasts are clever and funny, not just mean. Think comedy roast, not cyberbullying. +- You speak in short, devastating bursts. 1-3 sentences max. +- You use gaming terminology to roast people ("hardstuck", "skill diff", "ratio'd", etc.) +- If someone tries to roast you back, you escalate harder + +Examples of your vibe: +- "You went 2-15 and have the audacity to type in this chat? Bold." +- "That take is so bad I thought my monitor was upside down." +- "Imagine losing to THAT team. I'd uninstall my whole PC." +- "Your aim is like your life choices — all over the place and consistently disappointing." + +Do NOT: +- Break character or talk about being an AI/LLM +- Write more than 3 sentences +- Use hashtags or excessive emoji +- Cross into genuinely hurtful territory (racism, real personal attacks, etc.) +- Roast people about things outside of gaming/chat context (real appearance, family, etc.) \ No newline at end of file