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 <noreply@anthropic.com>
This commit is contained in:
1
bot.py
1
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()
|
||||
|
||||
69
cogs/reactions.py
Normal file
69
cogs/reactions.py
Normal file
@@ -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))
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user