From a8e8b63f5e3663c10642ab361af3dedd49c1ee95 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 1 Mar 2026 11:25:17 -0500 Subject: [PATCH] feat: add ReactionCog for ambient emoji reactions Add a new cog that gives the bot ambient presence by reacting to messages with contextual emoji chosen by the triage LLM. Includes RNG gating and per-channel cooldown to keep reactions sparse and natural. Co-Authored-By: Claude Opus 4.6 --- bot.py | 1 + cogs/reactions.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++ config.yaml | 5 ++++ 3 files changed, 75 insertions(+) create mode 100644 cogs/reactions.py diff --git a/bot.py b/bot.py index 2539c06..539907a 100644 --- a/bot.py +++ b/bot.py @@ -139,6 +139,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.reactions") # Global sync as fallback; guild-specific sync happens in on_ready await self.tree.sync() diff --git a/cogs/reactions.py b/cogs/reactions.py new file mode 100644 index 0000000..a984b69 --- /dev/null +++ b/cogs/reactions.py @@ -0,0 +1,69 @@ +import asyncio +import logging +import random +import time + +import discord +from discord.ext import commands + +logger = logging.getLogger("bcs.reactions") + + +class ReactionCog(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + # Per-channel timestamp of last reaction + self._last_reaction: dict[int, float] = {} + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + if message.author.bot or not message.guild: + return + + cfg = self.bot.config.get("reactions", {}) + if not cfg.get("enabled", False): + return + + # Skip empty messages + if not message.content or not message.content.strip(): + return + + # RNG gate + chance = cfg.get("chance", 0.15) + if random.random() > chance: + return + + # Per-channel cooldown + ch_id = message.channel.id + cooldown = cfg.get("cooldown_seconds", 45) + now = time.monotonic() + if now - self._last_reaction.get(ch_id, 0) < cooldown: + return + + # Fire and forget so we don't block anything + asyncio.create_task(self._try_react(message, ch_id, now)) + + async def _try_react(self, message: discord.Message, ch_id: int, now: float): + try: + emoji = await self.bot.llm.pick_reaction( + message.content, message.channel.name, + ) + if not emoji: + return + + await message.add_reaction(emoji) + self._last_reaction[ch_id] = now + logger.info( + "Reacted %s to %s in #%s: %s", + emoji, message.author.display_name, + message.channel.name, message.content[:60], + ) + except discord.HTTPException as e: + # Invalid emoji or missing permissions — silently skip + logger.debug("Reaction failed: %s", e) + except Exception: + logger.exception("Unexpected reaction error") + + +async def setup(bot: commands.Bot): + await bot.add_cog(ReactionCog(bot)) diff --git a/config.yaml b/config.yaml index 9b218ab..3283927 100644 --- a/config.yaml +++ b/config.yaml @@ -161,3 +161,8 @@ coherence: mobile_keyboard: "{username}'s thumbs are having a rough day." language_barrier: "Having trouble there, {username}? Take your time." default: "You okay there, {username}? That message was... something." + +reactions: + enabled: true + chance: 0.15 # Probability of evaluating a message for reaction + cooldown_seconds: 45 # Per-channel cooldown between reactions