diff --git a/bot.py b/bot.py index e92bf21..3a3b0e7 100644 --- a/bot.py +++ b/bot.py @@ -138,6 +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 new file mode 100644 index 0000000..32000cd --- /dev/null +++ b/cogs/wordle.py @@ -0,0 +1,200 @@ +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}]"] + + 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)) diff --git a/config.yaml b/config.yaml index d722819..b658e72 100644 --- a/config.yaml +++ b/config.yaml @@ -129,6 +129,12 @@ polls: duration_hours: 4 cooldown_minutes: 60 # Per-channel cooldown between auto-polls +wordle: + enabled: true + bot_name: "Wordle" # Discord bot name to watch for + reply_chance: 0.75 # Chance to comment on result summaries (0.0-1.0) + playing_reply_chance: 0.0 # Chance to comment on "was playing" messages (0 = never) + coherence: enabled: true drop_threshold: 0.3 # How far below baseline triggers alert