From 913827b5f3d4c09bbe33e00b4d2e7d5a54277836 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 22 Oct 2025 14:59:19 -0500 Subject: [PATCH] Refactored listener logic and added SpoilerListener --- bot.py | 2 + commands/soak/listener.py | 24 +--- commands/spoiler/CLAUDE.md | 207 +++++++++++++++++++++++++++++++++++ commands/spoiler/__init__.py | 54 +++++++++ commands/spoiler/listener.py | 94 ++++++++++++++++ utils/listeners.py | 141 ++++++++++++++++++++++++ 6 files changed, 504 insertions(+), 18 deletions(-) create mode 100644 commands/spoiler/CLAUDE.md create mode 100644 commands/spoiler/__init__.py create mode 100644 commands/spoiler/listener.py create mode 100644 utils/listeners.py diff --git a/bot.py b/bot.py index 16e8e87..1bb5a0a 100644 --- a/bot.py +++ b/bot.py @@ -120,6 +120,7 @@ class SBABot(commands.Bot): from commands.help import setup_help_commands from commands.profile import setup_profile_commands from commands.soak import setup_soak + from commands.spoiler import setup_spoiler from commands.injuries import setup_injuries from commands.gameplay import setup_gameplay @@ -137,6 +138,7 @@ class SBABot(commands.Bot): ("help", setup_help_commands), ("profile", setup_profile_commands), ("soak", setup_soak), + ("spoiler", setup_spoiler), ("injuries", setup_injuries), ("gameplay", setup_gameplay), ] diff --git a/commands/soak/listener.py b/commands/soak/listener.py index 5f60b48..ef2214f 100644 --- a/commands/soak/listener.py +++ b/commands/soak/listener.py @@ -3,20 +3,16 @@ Soak Message Listener Monitors all messages for soak mentions and responds with disappointment GIFs. """ -import re -import os import logging import discord from discord.ext import commands +from utils.listeners import should_process_message, COMMAND_FILTERS from .tracker import SoakTracker from .giphy_service import get_tier_for_seconds, get_disappointment_gif logger = logging.getLogger(f'{__name__}.SoakListener') -# Regex pattern to detect soak variations (whole word only) -SOAK_PATTERN = re.compile(r'\b(soak|soaking|soaked|soaker)\b', re.IGNORECASE) - class SoakListener(commands.Cog): """Listens for soak mentions and responds with appropriate disappointment.""" @@ -34,21 +30,13 @@ class SoakListener(commands.Cog): Args: message: Discord message object """ - # Ignore bot messages to prevent loops - if message.author.bot: + # Apply common message filters + if not should_process_message(message, *COMMAND_FILTERS): return - # Ignore messages that start with command prefix (legacy pattern) - if message.content.startswith('!'): - return - - # Check guild ID matches configured guild (optional security) - guild_id = os.environ.get('GUILD_ID') - if guild_id and message.guild and message.guild.id != int(guild_id): - return - - # Check if message contains soak - if not SOAK_PATTERN.search(message.content): + # Check if message contains ' soak' (listener-specific filter) + msg_text = message.content.lower() + if ' soak' not in msg_text: return logger.info(f"Soak detected in message from {message.author.name} (ID: {message.author.id})") diff --git a/commands/spoiler/CLAUDE.md b/commands/spoiler/CLAUDE.md new file mode 100644 index 0000000..0faa163 --- /dev/null +++ b/commands/spoiler/CLAUDE.md @@ -0,0 +1,207 @@ +# Spoiler Package Documentation +**Discord Bot v2.0 - Spoiler Detection System** + +## Overview + +The spoiler package monitors all messages for Discord spoiler tags (`||text||`) and pings the "Deez Watch" role when detected. This provides a fun, automated alert when users post spoilers in the server. + +## Package Structure + +``` +commands/spoiler/ +├── CLAUDE.md # This documentation +├── __init__.py # Package setup with resilient loading +└── listener.py # Spoiler detection listener +``` + +## Components + +### **SpoilerListener** (`listener.py`) + +**Purpose:** Monitors all messages for Discord spoiler syntax and posts role ping alerts. + +**Implementation:** +```python +class SpoilerListener(commands.Cog): + """Listens for spoiler tags and responds with Deez Watch role ping.""" + + @commands.Cog.listener(name='on_message') + async def on_message_listener(self, message: discord.Message): + # Uses shared message filters from utils.listeners + if not should_process_message(message, *COMMAND_FILTERS): + return + + # Detect Discord spoiler syntax (||) + spoiler_count = message.content.count("||") + if spoiler_count < 2: + return + + # Find and ping the "Deez Watch" role + deez_watch_role = discord.utils.get(message.guild.roles, name="Deez Watch") + if deez_watch_role: + await message.channel.send(f"{deez_watch_role.mention}!") + else: + # Fallback if role doesn't exist + await message.channel.send("Deez Watch!") +``` + +## Discord Spoiler Syntax + +Discord uses `||` markers to create spoiler tags: +- `||hidden text||` creates a single spoiler +- Multiple spoilers can exist in one message: `||spoiler 1|| some text ||spoiler 2||` +- The listener detects any message with 2+ instances of `||` (i.e., at least one complete spoiler tag) + +## Message Filtering + +Uses the shared `COMMAND_FILTERS` from `utils.listeners`, which includes: +- **Ignore bot messages** - Prevents bot loops +- **Ignore empty messages** - Messages with no content +- **Ignore command prefix** - Messages starting with '!' (legacy commands) +- **Ignore DMs** - Only process guild messages +- **Guild validation** - Only process messages from configured guild + +See `utils/listeners.py` for filter implementation details. + +## Logging + +The listener logs: +- **Startup:** When the cog is initialized +- **Detection:** When a spoiler is detected (with user info and spoiler count) +- **Success:** When the alert is posted with role mention +- **Warning:** When the Deez Watch role is not found (falls back to text) +- **Errors:** Permission issues or send failures + +**Example log output (with role):** +``` +INFO - SpoilerListener cog initialized +INFO - Spoiler detected in message from Username (ID: 123456789) with 2 spoiler tag(s) +DEBUG - Spoiler alert posted with role mention +``` + +**Example log output (without role):** +``` +INFO - Spoiler detected in message from Username (ID: 123456789) with 1 spoiler tag(s) +WARNING - Deez Watch role not found, posted without mention +``` + +## Error Handling + +**Permission Errors:** +- Catches `discord.Forbidden` when bot lacks send permissions +- Logs error with channel ID for troubleshooting + +**General Errors:** +- Catches all exceptions during message sending +- Logs with full stack trace for debugging +- Does not crash the bot or stop listening + +## Requirements + +**Discord Role:** +- A role named **"Deez Watch"** must exist in the guild +- Bot automatically looks up the role by name at runtime +- If role doesn't exist, bot falls back to posting "Deez Watch!" without mention + +**Permissions:** +- Bot needs **Send Messages** permission in channels +- Bot needs **Mention Everyone** permission to ping roles (if role is not mentionable) +- Role can be set as mentionable to avoid needing special permissions + +## Usage Examples + +**Single Spoiler (with role):** +``` +User: Hey did you know that ||Bruce Willis is dead the whole time||? +Bot: @Deez Watch! +``` + +**Multiple Spoilers:** +``` +User: ||Darth Vader|| is ||Luke's father|| +Bot: @Deez Watch! +``` + +**Fallback (no role):** +``` +User: Hey did you know that ||Bruce Willis is dead the whole time||? +Bot: Deez Watch! +``` + +**Edge Cases:** +- Single `||` marker - **Not detected** (incomplete spoiler) +- Text with `||` in code blocks - **Detected** (Discord doesn't parse spoilers in code blocks) +- Empty spoilers `||||` - **Detected** (valid Discord syntax) + +## Testing + +**Setup:** +1. Create a role named "Deez Watch" in your Discord server (recommended) +2. Set the role as mentionable OR ensure bot has "Mention Everyone" permission + +**Manual Testing:** +1. Start the bot in development mode +2. Post a message with `||test||` in the configured guild +3. Verify bot responds with @Deez Watch! (role mention) + +**Test Cases:** +- ✅ Single spoiler tag with role mention +- ✅ Multiple spoiler tags +- ✅ Fallback to text if role doesn't exist +- ✅ Bot ignores own messages +- ✅ Bot ignores DMs +- ✅ Bot ignores messages from other guilds +- ✅ Bot ignores command messages (starting with '!') + +## Performance Considerations + +**Impact:** +- Listener processes every non-filtered message in the guild +- String counting (`message.content.count("||")`) is O(n) but fast for typical message lengths +- Only sends one message per detection (no loops) + +**Optimization:** +- Early returns via message filters reduce processing +- Simple string operation (no regex needed) +- Async operations prevent blocking + +## Future Enhancements + +Potential improvements for the spoiler system: + +1. **Configurable Response:** + - Custom responses per guild + - Random selection from multiple responses + - Embed responses with more context + +2. **Cooldown System:** + - Prevent spam by rate-limiting responses + - Per-channel or per-user cooldowns + - Configurable cooldown duration + +3. **Statistics Tracking:** + - Track spoiler counts per user + - Leaderboard of "most spoiler-prone" users + - Channel statistics + +4. **Advanced Detection:** + - Detect spoiler context (movie, game, book) + - Different responses based on content + - Integration with spoiler databases + +5. **Permissions:** + - Allow certain roles to bypass detection + - Channel-specific enable/disable + - Opt-in/opt-out system + +## Related Files + +- **`utils/listeners.py`** - Shared message filtering utilities +- **`commands/soak/listener.py`** - Similar listener pattern (template used) +- **`bot.py`** - Package registration and loading + +--- + +**Last Updated:** October 2025 +**Maintenance:** Keep logging and error handling consistent with other listeners +**Next Review:** When additional listener features are requested diff --git a/commands/spoiler/__init__.py b/commands/spoiler/__init__.py new file mode 100644 index 0000000..4feb5d0 --- /dev/null +++ b/commands/spoiler/__init__.py @@ -0,0 +1,54 @@ +""" +Spoiler Commands Package + +This package contains the spoiler listener that watches for Discord spoiler tags +and posts "Deez Watch!" alerts. +""" +import logging +from discord.ext import commands + +from .listener import SpoilerListener + +logger = logging.getLogger(__name__) + + +async def setup_spoiler(bot: commands.Bot): + """ + Setup spoiler listener module. + + Returns: + tuple: (successful_count, failed_count, failed_modules) + """ + # Define spoiler cogs to load + spoiler_cogs = [ + ("SpoilerListener", SpoilerListener), + ] + + successful = 0 + failed = 0 + failed_modules = [] + + for cog_name, cog_class in spoiler_cogs: + try: + await bot.add_cog(cog_class(bot)) + logger.info(f"✅ Loaded {cog_name}") + successful += 1 + except Exception as e: + logger.error(f"❌ Failed to load {cog_name}: {e}", exc_info=True) + failed += 1 + failed_modules.append(cog_name) + + # Log summary + if failed == 0: + logger.info(f"🎉 All {successful} spoiler module(s) loaded successfully") + else: + logger.warning( + f"⚠️ Spoiler modules loaded with issues: " + f"{successful} successful, {failed} failed" + ) + + return successful, failed, failed_modules + + +# Export the setup function for easy importing +__all__ = ['setup_spoiler', 'SpoilerListener'] diff --git a/commands/spoiler/listener.py b/commands/spoiler/listener.py new file mode 100644 index 0000000..d4bfa3f --- /dev/null +++ b/commands/spoiler/listener.py @@ -0,0 +1,94 @@ +""" +Spoiler Message Listener + +Monitors all messages for Discord spoiler tags and responds with "Deez Watch!". +Discord spoilers use || markers, so we detect messages with two or more instances. +""" +import logging +import random +import discord +from discord.ext import commands + +from utils.listeners import should_process_message, COMMAND_FILTERS + +logger = logging.getLogger(f'{__name__}.SpoilerListener') + + +class SpoilerListener(commands.Cog): + """Listens for spoiler tags and responds with Deez Watch!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + logger.info("SpoilerListener cog initialized") + + @commands.Cog.listener(name='on_message') + async def on_message_listener(self, message: discord.Message): + """ + Listen for messages containing Discord spoiler tags (||). + + Args: + message: Discord message object + """ + # Apply common message filters + if not should_process_message(message, *COMMAND_FILTERS): + return + + # Check if message contains two or more instances of "||" (spoiler syntax) + spoiler_count = message.content.count("||") + if spoiler_count < 2: + return + + logger.info( + f"Spoiler detected in message from {message.author.name} " + f"(ID: {message.author.id}) with {spoiler_count // 2} spoiler tag(s)" + ) + + split_text = message.content.split('||') + spoiler_text = split_text[1] + if len(split_text) > 3 and 'z' not in spoiler_text: + chance = 'Low' + elif 8 <= len(spoiler_text) <= 10: + chance = 'High' + elif 'z' in spoiler_text: + chance = 'Medium' + else: + d1000 = random.randint(1, 1000) + if d1000 <= 300: + chance = 'Low' + elif d1000 <= 600: + chance = 'Medium' + elif d1000 <= 900: + chance = 'High' + elif d1000 <= 950: + chance = 'Miniscule' + elif d1000 <= 980: + chance = 'Throbbing' + else: + chance = 'Deez Nuts' + + try: + # Find the "Deez Watch" role in the guild + deez_watch_role = None + if message.guild: + deez_watch_role = discord.utils.get(message.guild.roles, name="Deez Watch") + + # Post response to channel with role ping if available + if deez_watch_role: + await message.channel.send(f"{deez_watch_role.mention} there is a **{chance}** chance this is a deez nuts joke!") + logger.debug("Spoiler alert posted with role mention") + else: + # Fallback if role doesn't exist + await message.channel.send("Deez Watch!") + logger.warning("Deez Watch role not found, posted without mention") + + except discord.Forbidden: + logger.error( + f"Missing permissions to send message in channel {message.channel.id}" + ) + except Exception as e: + logger.error(f"Error sending spoiler response: {e}", exc_info=True) + + +async def setup(bot: commands.Bot): + """Load the spoiler listener cog.""" + await bot.add_cog(SpoilerListener(bot)) diff --git a/utils/listeners.py b/utils/listeners.py new file mode 100644 index 0000000..b9e2987 --- /dev/null +++ b/utils/listeners.py @@ -0,0 +1,141 @@ +""" +Message Listener Utilities + +Provides reusable components for on_message listeners including common message +filtering patterns. +""" +import logging +from typing import Callable +import discord +from config import get_config + +logger = logging.getLogger(f'{__name__}.message_filters') + + +def should_ignore_bot_messages(message: discord.Message) -> bool: + """ + Check if message should be ignored because it's from a bot. + + Args: + message: Discord message object + + Returns: + bool: True if message should be ignored (author is a bot) + """ + return message.author.bot + + +def should_ignore_empty_messages(message: discord.Message) -> bool: + """ + Check if message should be ignored because it has no content. + + Args: + message: Discord message object + + Returns: + bool: True if message should be ignored (no content) + """ + return not message.content + + +def should_ignore_command_prefix(message: discord.Message, prefix: str = '!') -> bool: + """ + Check if message should be ignored because it starts with a command prefix. + + Args: + message: Discord message object + prefix: Command prefix to check for (default: '!') + + Returns: + bool: True if message should be ignored (starts with prefix) + """ + return message.content.startswith(prefix) + + +def should_ignore_dms(message: discord.Message) -> bool: + """ + Check if message should be ignored because it's a DM (no guild). + + Args: + message: Discord message object + + Returns: + bool: True if message should be ignored (no guild) + """ + return not message.guild + + +def should_ignore_wrong_guild(message: discord.Message) -> bool: + """ + Check if message should be ignored because it's from the wrong guild. + + Args: + message: Discord message object + + Returns: + bool: True if message should be ignored (wrong guild or no guild) + """ + if not message.guild: + return True + + guild_id = get_config().guild_id + return message.guild.id != guild_id + + +def should_process_message( + message: discord.Message, + *filters: Callable[[discord.Message], bool] +) -> bool: + """ + Check if a message should be processed based on provided filters. + + Args: + message: Discord message object + *filters: Variable number of filter functions that return True if message should be ignored + + Returns: + bool: True if message should be processed (all filters returned False), + False if message should be ignored (any filter returned True) + + Example: + if should_process_message( + message, + should_ignore_bot_messages, + should_ignore_empty_messages, + should_ignore_dms, + should_ignore_wrong_guild + ): + # Process the message + pass + """ + for filter_func in filters: + if filter_func(message): + return False + + return True + + +# Pre-defined filter sets for common use cases + +BASIC_FILTERS = ( + should_ignore_bot_messages, + should_ignore_empty_messages, +) +"""Basic filters: Ignore bots and empty messages.""" + +GUILD_FILTERS = ( + should_ignore_bot_messages, + should_ignore_empty_messages, + should_ignore_dms, + should_ignore_wrong_guild, +) +"""Guild filters: Basic filters + guild validation.""" + +COMMAND_FILTERS = ( + should_ignore_bot_messages, + should_ignore_empty_messages, + should_ignore_command_prefix, + should_ignore_dms, + should_ignore_wrong_guild, +) +"""Command filters: Guild filters + command prefix check."""