diff --git a/CLAUDE.md b/CLAUDE.md index bac3d92..a6c8ad4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ LLM calls use OpenAI tool-calling for structured output (`ANALYSIS_TOOL`, `CONVE - **`cogs/sentiment.py` (SentimentCog)**: Core moderation engine. Listens to all messages, debounces per-channel (batches messages within `batch_window_seconds`), runs triage → escalation analysis, issues warnings/mutes. Also handles mention-triggered conversation scans and game channel redirects. Flushes dirty user states to DB every 5 minutes. - **`cogs/chat.py` (ChatCog)**: Conversational AI. Responds to @mentions, replies to bot messages, proactive replies based on mode config. Handles image roasts via vision model. Strips leaked LLM metadata brackets from responses. - **`cogs/commands.py` (CommandsCog)**: Slash commands — `/dramareport`, `/dramascore`, `/bcs-status`, `/bcs-threshold`, `/bcs-reset`, `/bcs-immune`, `/bcs-history`, `/bcs-scan`, `/bcs-test`, `/bcs-notes`, `/bcs-mode`. -- **`cogs/wordle.py` (WordleCog)**: Watches for Wordle bot messages and generates fun commentary on results. + ### Key Utilities diff --git a/bot.py b/bot.py index 53de917..b232f38 100644 --- a/bot.py +++ b/bot.py @@ -138,7 +138,7 @@ class BCSBot(commands.Bot): await self.load_extension("cogs.sentiment") await self.load_extension("cogs.commands") await self.load_extension("cogs.chat") - await self.load_extension("cogs.wordle") + await self.tree.sync() logger.info("Slash commands synced.") diff --git a/cogs/wordle.py b/cogs/wordle.py deleted file mode 100644 index af24c97..0000000 --- a/cogs/wordle.py +++ /dev/null @@ -1,205 +0,0 @@ -import logging -import random -import re -from collections import deque -from pathlib import Path - -import discord -from discord.ext import commands - -logger = logging.getLogger("bcs.wordle") - -_PROMPTS_DIR = Path(__file__).resolve().parent.parent / "prompts" - -_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] - - -def _parse_wordle_embeds(message: discord.Message) -> dict | None: - """Extract useful info from a Wordle bot message. - - Returns a dict with keys like 'type', 'summary', 'scores', 'streak', 'wordle_number' - or None if this isn't a recognizable Wordle result message. - """ - if not message.embeds: - return None - - full_text = "" - wordle_number = None - - for embed in message.embeds: - if embed.description: - full_text += embed.description + "\n" - if embed.title: - full_text += embed.title + "\n" - m = re.search(r"Wordle No\.\s*(\d+)", embed.title) - if m: - wordle_number = int(m.group(1)) - - if not full_text.strip(): - return None - - # Detect result messages (contain score patterns like "3/6:") - score_pattern = re.findall(r"(\d/6):\s*@?(.+?)(?:\n|$)", full_text) - streak_match = re.search(r"(\d+)\s*day streak", full_text) - - if score_pattern: - scores = [{"score": s[0], "player": s[1].strip()} for s in score_pattern] - return { - "type": "results", - "wordle_number": wordle_number, - "streak": int(streak_match.group(1)) if streak_match else None, - "scores": scores, - "summary": full_text.strip(), - } - - # Detect "was playing" messages - if "was playing" in full_text: - return { - "type": "playing", - "wordle_number": wordle_number, - "summary": full_text.strip(), - } - - return None - - -class WordleCog(commands.Cog): - def __init__(self, bot: commands.Bot): - self.bot = bot - self._chat_history: dict[int, deque] = {} - - def _get_active_prompt(self) -> str: - mode_config = self.bot.get_mode_config() - prompt_file = mode_config.get("prompt_file", "chat_personality.txt") - return _load_prompt(prompt_file) - - def _get_wordle_config(self) -> dict: - return self.bot.config.get("wordle", {}) - - @commands.Cog.listener() - async def on_message(self, message: discord.Message): - if not message.author.bot: - return - if not message.guild: - return - - config = self._get_wordle_config() - if not config.get("enabled", False): - return - - # Match the Wordle bot by name - bot_name = config.get("bot_name", "Wordle") - if message.author.name != bot_name: - return - - parsed = _parse_wordle_embeds(message) - if not parsed: - return - - # Only comment on results, not "playing" notifications - if parsed["type"] == "playing": - reply_chance = config.get("playing_reply_chance", 0.0) - if reply_chance <= 0 or random.random() > reply_chance: - return - else: - reply_chance = config.get("reply_chance", 0.5) - if random.random() > reply_chance: - return - - # Build context for the LLM - context_parts = [ - f"[Wordle bot posted in #{message.channel.name}]", - "[Wordle scoring: players guess a 5-letter word in up to 6 tries. " - "LOWER is BETTER — 1/6 is a genius guess, 2/6 is incredible, 3/6 is great, " - "4/6 is mediocre, 5/6 is rough, 6/6 barely scraped by, X/6 means they failed]", - ] - - if parsed["type"] == "results": - context_parts.append("[This is a Wordle results summary]") - if parsed.get("streak"): - context_parts.append(f"[Group streak: {parsed['streak']} days]") - if parsed.get("wordle_number"): - context_parts.append(f"[Wordle #{parsed['wordle_number']}]") - for s in parsed.get("scores", []): - context_parts.append(f"[{s['player']} scored {s['score']}]") - - # Identify the winner (lowest score = best) - scores = parsed.get("scores", []) - if scores: - best = min(scores, key=lambda s: int(s["score"][0])) - worst = max(scores, key=lambda s: int(s["score"][0])) - if best != worst: - context_parts.append( - f"[{best['player']} won with {best['score']}, " - f"{worst['player']} came last with {worst['score']}]" - ) - elif parsed["type"] == "playing": - context_parts.append(f"[Someone is currently playing Wordle]") - context_parts.append(f"[{parsed['summary']}]") - - prompt_context = "\n".join(context_parts) - user_msg = ( - f"{prompt_context}\n" - f"React to this Wordle update with a short, fun comment. " - f"Keep it to 1-2 sentences." - ) - - ch_id = message.channel.id - if ch_id not in self._chat_history: - self._chat_history[ch_id] = deque(maxlen=6) - - self._chat_history[ch_id].append({"role": "user", "content": user_msg}) - - active_prompt = self._get_active_prompt() - - recent_bot_replies = [ - m["content"][:150] for m in self._chat_history[ch_id] - if m["role"] == "assistant" - ][-3:] - - typing_ctx = None - - async def start_typing(): - nonlocal typing_ctx - typing_ctx = message.channel.typing() - await typing_ctx.__aenter__() - - response = await self.bot.llm_chat.chat( - list(self._chat_history[ch_id]), - active_prompt, - on_first_token=start_typing, - recent_bot_replies=recent_bot_replies, - ) - - if typing_ctx: - await typing_ctx.__aexit__(None, None, None) - - # Strip leaked metadata brackets (same as chat.py) - if response: - segments = re.split(r"^\s*\[[^\]]*\]\s*$", response, flags=re.MULTILINE) - segments = [s.strip() for s in segments if s.strip()] - response = segments[-1] if segments else "" - - if not response: - logger.warning("LLM returned no response for Wordle comment in #%s", message.channel.name) - return - - self._chat_history[ch_id].append({"role": "assistant", "content": response}) - - await message.reply(response, mention_author=False) - logger.info( - "Wordle %s reply in #%s: %s", - parsed["type"], - message.channel.name, - response[:100], - ) - - -async def setup(bot: commands.Bot): - await bot.add_cog(WordleCog(bot))