Merge pull request #6 from calcorum/refactor-listeners

Refactored listener logic and added SpoilerListener
This commit is contained in:
Cal Corum 2025-10-22 15:03:21 -05:00 committed by GitHub
commit f87994b188
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 504 additions and 18 deletions

2
bot.py
View File

@ -120,6 +120,7 @@ class SBABot(commands.Bot):
from commands.help import setup_help_commands from commands.help import setup_help_commands
from commands.profile import setup_profile_commands from commands.profile import setup_profile_commands
from commands.soak import setup_soak from commands.soak import setup_soak
from commands.spoiler import setup_spoiler
from commands.injuries import setup_injuries from commands.injuries import setup_injuries
from commands.gameplay import setup_gameplay from commands.gameplay import setup_gameplay
@ -137,6 +138,7 @@ class SBABot(commands.Bot):
("help", setup_help_commands), ("help", setup_help_commands),
("profile", setup_profile_commands), ("profile", setup_profile_commands),
("soak", setup_soak), ("soak", setup_soak),
("spoiler", setup_spoiler),
("injuries", setup_injuries), ("injuries", setup_injuries),
("gameplay", setup_gameplay), ("gameplay", setup_gameplay),
] ]

View File

@ -3,20 +3,16 @@ Soak Message Listener
Monitors all messages for soak mentions and responds with disappointment GIFs. Monitors all messages for soak mentions and responds with disappointment GIFs.
""" """
import re
import os
import logging import logging
import discord import discord
from discord.ext import commands from discord.ext import commands
from utils.listeners import should_process_message, COMMAND_FILTERS
from .tracker import SoakTracker from .tracker import SoakTracker
from .giphy_service import get_tier_for_seconds, get_disappointment_gif from .giphy_service import get_tier_for_seconds, get_disappointment_gif
logger = logging.getLogger(f'{__name__}.SoakListener') 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): class SoakListener(commands.Cog):
"""Listens for soak mentions and responds with appropriate disappointment.""" """Listens for soak mentions and responds with appropriate disappointment."""
@ -34,21 +30,13 @@ class SoakListener(commands.Cog):
Args: Args:
message: Discord message object message: Discord message object
""" """
# Ignore bot messages to prevent loops # Apply common message filters
if message.author.bot: if not should_process_message(message, *COMMAND_FILTERS):
return return
# Ignore messages that start with command prefix (legacy pattern) # Check if message contains ' soak' (listener-specific filter)
if message.content.startswith('!'): msg_text = message.content.lower()
return if ' soak' not in msg_text:
# 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):
return return
logger.info(f"Soak detected in message from {message.author.name} (ID: {message.author.id})") logger.info(f"Soak detected in message from {message.author.name} (ID: {message.author.id})")

207
commands/spoiler/CLAUDE.md Normal file
View File

@ -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

View File

@ -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']

View File

@ -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))

141
utils/listeners.py Normal file
View File

@ -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."""