Add Wordle commentary: bot reacts to Wordle results with mode-appropriate comments
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
bot.py
1
bot.py
@@ -138,6 +138,7 @@ class BCSBot(commands.Bot):
|
|||||||
await self.load_extension("cogs.sentiment")
|
await self.load_extension("cogs.sentiment")
|
||||||
await self.load_extension("cogs.commands")
|
await self.load_extension("cogs.commands")
|
||||||
await self.load_extension("cogs.chat")
|
await self.load_extension("cogs.chat")
|
||||||
|
await self.load_extension("cogs.wordle")
|
||||||
await self.tree.sync()
|
await self.tree.sync()
|
||||||
logger.info("Slash commands synced.")
|
logger.info("Slash commands synced.")
|
||||||
|
|
||||||
|
|||||||
200
cogs/wordle.py
Normal file
200
cogs/wordle.py
Normal file
@@ -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))
|
||||||
@@ -129,6 +129,12 @@ polls:
|
|||||||
duration_hours: 4
|
duration_hours: 4
|
||||||
cooldown_minutes: 60 # Per-channel cooldown between auto-polls
|
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:
|
coherence:
|
||||||
enabled: true
|
enabled: true
|
||||||
drop_threshold: 0.3 # How far below baseline triggers alert
|
drop_threshold: 0.3 # How far below baseline triggers alert
|
||||||
|
|||||||
Reference in New Issue
Block a user