Refactored listener logic and added SpoilerListener
This commit is contained in:
parent
003d8e32b6
commit
913827b5f3
2
bot.py
2
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),
|
||||
]
|
||||
|
||||
@ -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})")
|
||||
|
||||
207
commands/spoiler/CLAUDE.md
Normal file
207
commands/spoiler/CLAUDE.md
Normal 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
|
||||
54
commands/spoiler/__init__.py
Normal file
54
commands/spoiler/__init__.py
Normal 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']
|
||||
94
commands/spoiler/listener.py
Normal file
94
commands/spoiler/listener.py
Normal 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
141
utils/listeners.py
Normal 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."""
|
||||
Loading…
Reference in New Issue
Block a user