diff --git a/bot.py b/bot.py index 263a007..3febd33 100644 --- a/bot.py +++ b/bot.py @@ -119,6 +119,7 @@ class SBABot(commands.Bot): from commands.utilities import setup_utilities from commands.help import setup_help_commands from commands.profile import setup_profile_commands + from commands.soak import setup_soak # Define command packages to load command_packages = [ @@ -133,6 +134,7 @@ class SBABot(commands.Bot): ("utilities", setup_utilities), ("help", setup_help_commands), ("profile", setup_profile_commands), + ("soak", setup_soak), ] total_successful = 0 diff --git a/commands/soak/__init__.py b/commands/soak/__init__.py new file mode 100644 index 0000000..f53a564 --- /dev/null +++ b/commands/soak/__init__.py @@ -0,0 +1,53 @@ +""" +Soak Easter Egg Package + +Monitors for "soak" mentions and responds with disappointment GIFs. +The more recently it was mentioned, the more disappointed the response. +""" +import logging +from discord.ext import commands + +from .listener import SoakListener +from .info import SoakInfoCommands + +logger = logging.getLogger(__name__) + + +async def setup_soak(bot: commands.Bot): + """ + Setup all soak command modules. + + Returns: + tuple: (successful_count, failed_count, failed_modules) + """ + # Define all soak cogs to load + soak_cogs = [ + ("SoakListener", SoakListener), + ("SoakInfoCommands", SoakInfoCommands), + ] + + successful = 0 + failed = 0 + failed_modules = [] + + for cog_name, cog_class in soak_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} soak modules loaded successfully") + else: + logger.warning(f"āš ļø Soak commands loaded with issues: {successful} successful, {failed} failed") + + return successful, failed, failed_modules + + +# Export the setup function and classes for easy importing +__all__ = ['setup_soak', 'SoakListener', 'SoakInfoCommands'] diff --git a/commands/soak/giphy_service.py b/commands/soak/giphy_service.py new file mode 100644 index 0000000..aa44984 --- /dev/null +++ b/commands/soak/giphy_service.py @@ -0,0 +1,195 @@ +""" +Giphy Service for Soak Easter Egg + +Provides async interface to Giphy API with disappointment-based search phrases. +""" +import random +import logging +from typing import List, Optional +import aiohttp + +logger = logging.getLogger(f'{__name__}.GiphyService') + +# Giphy API configuration +GIPHY_API_KEY = 'H86xibttEuUcslgmMM6uu74IgLEZ7UOD' +GIPHY_TRANSLATE_URL = 'https://api.giphy.com/v1/gifs/translate' + +# Disappointment tier configuration +DISAPPOINTMENT_TIERS = { + 'tier_1': { + 'max_seconds': 1800, # 30 minutes + 'phrases': [ + "extremely disappointed", + "so disappointed", + "are you kidding me", + "seriously", + "unbelievable" + ], + 'description': "Maximum Disappointment" + }, + 'tier_2': { + 'max_seconds': 7200, # 2 hours + 'phrases': [ + "very disappointed", + "can't believe you", + "not happy", + "shame on you", + "facepalm" + ], + 'description': "Severe Disappointment" + }, + 'tier_3': { + 'max_seconds': 21600, # 6 hours + 'phrases': [ + "disappointed", + "not impressed", + "shaking head", + "eye roll", + "really" + ], + 'description': "Strong Disappointment" + }, + 'tier_4': { + 'max_seconds': 86400, # 24 hours + 'phrases': [ + "mildly disappointed", + "not great", + "could be better", + "sigh", + "seriously" + ], + 'description': "Moderate Disappointment" + }, + 'tier_5': { + 'max_seconds': 604800, # 7 days + 'phrases': [ + "slightly disappointed", + "oh well", + "shrug", + "meh", + "not bad" + ], + 'description': "Mild Disappointment" + }, + 'tier_6': { + 'max_seconds': float('inf'), # 7+ days + 'phrases': [ + "not disappointed", + "relieved", + "proud", + "been worse", + "fine i guess" + ], + 'description': "Minimal Disappointment" + }, + 'first_ever': { + 'phrases': [ + "here we go", + "oh boy", + "uh oh", + "getting started", + "and so it begins" + ], + 'description': "The Beginning" + } +} + + +def get_tier_for_seconds(seconds_elapsed: Optional[int]) -> str: + """ + Determine disappointment tier based on elapsed time. + + Args: + seconds_elapsed: Seconds since last soak, or None for first ever + + Returns: + Tier key string (e.g., 'tier_1', 'first_ever') + """ + if seconds_elapsed is None: + return 'first_ever' + + for tier_key in ['tier_1', 'tier_2', 'tier_3', 'tier_4', 'tier_5', 'tier_6']: + if seconds_elapsed <= DISAPPOINTMENT_TIERS[tier_key]['max_seconds']: + return tier_key + + return 'tier_6' # Fallback to lowest disappointment + + +def get_random_phrase_for_tier(tier_key: str) -> str: + """ + Get a random search phrase from the specified tier. + + Args: + tier_key: Tier identifier (e.g., 'tier_1', 'first_ever') + + Returns: + Random search phrase from that tier + """ + phrases = DISAPPOINTMENT_TIERS[tier_key]['phrases'] + return random.choice(phrases) + + +def get_tier_description(tier_key: str) -> str: + """ + Get the human-readable description for a tier. + + Args: + tier_key: Tier identifier + + Returns: + Description string + """ + return DISAPPOINTMENT_TIERS[tier_key]['description'] + + +async def get_disappointment_gif(tier_key: str) -> Optional[str]: + """ + Fetch a GIF from Giphy based on disappointment tier. + + Randomly selects a search phrase from the tier and queries Giphy. + Filters out Trump GIFs (legacy behavior). + Falls back to trying other phrases if first fails. + + Args: + tier_key: Tier identifier (e.g., 'tier_1', 'first_ever') + + Returns: + GIF URL string, or None if all attempts fail + """ + phrases = DISAPPOINTMENT_TIERS[tier_key]['phrases'] + + # Shuffle phrases for variety and retry capability + shuffled_phrases = random.sample(phrases, len(phrases)) + + async with aiohttp.ClientSession() as session: + for phrase in shuffled_phrases: + try: + url = f"{GIPHY_TRANSLATE_URL}?s={phrase}&api_key={GIPHY_API_KEY}" + + async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: + if resp.status == 200: + data = await resp.json() + + # Filter out Trump GIFs (legacy behavior) + gif_title = data.get('data', {}).get('title', '').lower() + if 'trump' in gif_title: + logger.debug(f"Filtered out Trump GIF for phrase: {phrase}") + continue + + gif_url = data.get('data', {}).get('url') + if gif_url: + logger.info(f"Successfully fetched GIF for phrase: {phrase}") + return gif_url + else: + logger.warning(f"No GIF URL in response for phrase: {phrase}") + else: + logger.warning(f"Giphy API returned status {resp.status} for phrase: {phrase}") + + except aiohttp.ClientError as e: + logger.error(f"HTTP error fetching GIF for phrase '{phrase}': {e}") + except Exception as e: + logger.error(f"Unexpected error fetching GIF for phrase '{phrase}': {e}") + + # All phrases failed + logger.error(f"Failed to fetch any GIF for tier: {tier_key}") + return None diff --git a/commands/soak/info.py b/commands/soak/info.py new file mode 100644 index 0000000..3172c4f --- /dev/null +++ b/commands/soak/info.py @@ -0,0 +1,127 @@ +""" +Soak Info Commands + +Provides information about soak mentions without triggering the easter egg. +""" +import discord +from discord import app_commands +from discord.ext import commands + +from utils.decorators import logged_command +from utils.logging import get_contextual_logger +from views.embeds import EmbedTemplate, EmbedColors +from .tracker import SoakTracker +from .giphy_service import get_tier_for_seconds, get_tier_description + + +class SoakInfoCommands(commands.Cog): + """Soak information command handlers.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.logger = get_contextual_logger(f'{__name__}.SoakInfoCommands') + self.tracker = SoakTracker() + self.logger.info("SoakInfoCommands cog initialized") + + @app_commands.command(name="lastsoak", description="Get information about the last soak mention") + @logged_command("/lastsoak") + async def last_soak(self, interaction: discord.Interaction): + """Show information about the last soak mention.""" + await interaction.response.defer(ephemeral=True) + + last_soak = self.tracker.get_last_soak() + + # Handle case where soak has never been mentioned + if not last_soak: + embed = EmbedTemplate.info( + title="šŸ“Š Last Soak", + description="No one has said the forbidden word yet. 🤫" + ) + embed.add_field( + name="Total Mentions", + value="0", + inline=False + ) + await interaction.followup.send(embed=embed) + return + + # Calculate time since last soak + time_since = self.tracker.get_time_since_last_soak() + total_count = self.tracker.get_soak_count() + + # Determine disappointment tier + tier_key = get_tier_for_seconds(int(time_since.total_seconds()) if time_since else None) + tier_description = get_tier_description(tier_key) + + # Create embed + embed = EmbedTemplate.create_base_embed( + title="šŸ“Š Last Soak", + description="Information about the most recent soak mention", + color=EmbedColors.INFO + ) + + # Parse timestamp for Discord formatting + try: + from datetime import datetime + timestamp_str = last_soak["timestamp"] + if timestamp_str.endswith('Z'): + timestamp_str = timestamp_str[:-1] + '+00:00' + timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) + unix_timestamp = int(timestamp.timestamp()) + + # Add relative time with warning if very recent + time_field_value = f"" + if time_since and time_since.total_seconds() < 1800: # Less than 30 minutes + time_field_value += "\n\n😤 Way too soon!" + + embed.add_field( + name="Last Mentioned", + value=time_field_value, + inline=False + ) + except Exception as e: + self.logger.error(f"Error parsing timestamp: {e}") + embed.add_field( + name="Last Mentioned", + value="Error parsing timestamp", + inline=False + ) + + # Add user info + user_mention = f"<@{last_soak['user_id']}>" + display_name = last_soak.get('display_name', last_soak.get('username', 'Unknown')) + embed.add_field( + name="By", + value=f"{user_mention} ({display_name})", + inline=True + ) + + # Add message link + try: + guild_id = interaction.guild_id + channel_id = last_soak['channel_id'] + message_id = last_soak['message_id'] + jump_url = f"https://discord.com/channels/{guild_id}/{channel_id}/{message_id}" + embed.add_field( + name="Message", + value=f"[Jump to message]({jump_url})", + inline=True + ) + except Exception as e: + self.logger.error(f"Error creating jump URL: {e}") + + # Add total count + embed.add_field( + name="Total Mentions", + value=str(total_count), + inline=True + ) + + # Add disappointment level + embed.add_field( + name="Disappointment Level", + value=f"{tier_key.replace('_', ' ').title()}: {tier_description}", + inline=False + ) + + await interaction.followup.send(embed=embed) diff --git a/commands/soak/listener.py b/commands/soak/listener.py new file mode 100644 index 0000000..5f60b48 --- /dev/null +++ b/commands/soak/listener.py @@ -0,0 +1,128 @@ +""" +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 .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.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.tracker = SoakTracker() + logger.info("SoakListener cog initialized") + + @commands.Cog.listener(name='on_message') + async def on_message_listener(self, message: discord.Message): + """ + Listen for messages containing soak mentions. + + Args: + message: Discord message object + """ + # Ignore bot messages to prevent loops + if message.author.bot: + 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): + return + + logger.info(f"Soak detected in message from {message.author.name} (ID: {message.author.id})") + + try: + # Get time since last soak + time_since = self.tracker.get_time_since_last_soak() + + # Determine disappointment tier + seconds_elapsed = int(time_since.total_seconds()) if time_since else None + tier_key = get_tier_for_seconds(seconds_elapsed) + + logger.info(f"Disappointment tier: {tier_key} (elapsed: {seconds_elapsed}s)") + + # Format time string for message + time_string = self._format_time_string(time_since) + + # Fetch GIF from Giphy + gif_url = await get_disappointment_gif(tier_key) + + # Post response to channel + try: + if gif_url: + # Post message with GIF + await message.channel.send(f"It has been [{time_string}]({gif_url}) since soaking was mentioned.") + else: + # Fallback to text-only with emoji if GIF fetch failed + await message.channel.send(f"šŸ˜ž It has been {time_string} since soaking was mentioned.") + logger.warning("Failed to fetch GIF, sent text-only response") + + 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 soak response: {e}") + + # Record this soak mention + self.tracker.record_soak( + user_id=message.author.id, + username=message.author.name, + display_name=message.author.display_name, + channel_id=message.channel.id, + message_id=message.id + ) + + except Exception as e: + logger.error(f"Error processing soak mention: {e}", exc_info=True) + + def _format_time_string(self, time_since) -> str: + """ + Format timedelta into human-readable string. + + Args: + time_since: timedelta object or None + + Returns: + Formatted time string (e.g., "5 minutes", "2 hours", "3 days") + """ + if time_since is None: + return "never" + + total_seconds = int(time_since.total_seconds()) + + if total_seconds < 60: + # Less than 1 minute + return f"{total_seconds} seconds" + elif total_seconds < 3600: + # Less than 1 hour + minutes = total_seconds // 60 + return f"{minutes} minute{'s' if minutes != 1 else ''}" + elif total_seconds < 86400: + # Less than 1 day + hours = total_seconds // 3600 + return f"{hours} hour{'s' if hours != 1 else ''}" + else: + # 1 day or more + days = time_since.days + return f"{days} day{'s' if days != 1 else ''}" diff --git a/commands/soak/tracker.py b/commands/soak/tracker.py new file mode 100644 index 0000000..9aaa1b3 --- /dev/null +++ b/commands/soak/tracker.py @@ -0,0 +1,176 @@ +""" +Soak Tracker + +Provides persistent tracking of "soak" mentions using JSON file storage. +""" +import json +import logging +from datetime import datetime, timedelta, UTC +from pathlib import Path +from typing import Dict, List, Optional, Any + +logger = logging.getLogger(f'{__name__}.SoakTracker') + + +class SoakTracker: + """ + Tracks "soak" mentions with JSON file persistence. + + Features: + - Persistent storage across bot restarts + - Mention recording with full history + - Time-based calculations for disappointment tiers + """ + + def __init__(self, data_file: str = "storage/soak_data.json"): + """ + Initialize the soak tracker. + + Args: + data_file: Path to the JSON data file + """ + self.data_file = Path(data_file) + self.data_file.parent.mkdir(exist_ok=True) + self._data: Dict[str, Any] = {} + self.load_data() + + def load_data(self) -> None: + """Load soak data from JSON file.""" + try: + if self.data_file.exists(): + with open(self.data_file, 'r') as f: + self._data = json.load(f) + logger.debug(f"Loaded soak data: {self._data.get('total_count', 0)} total soaks") + else: + self._data = { + "last_soak": None, + "total_count": 0, + "history": [] + } + logger.info("No existing soak data found, starting fresh") + except Exception as e: + logger.error(f"Failed to load soak data: {e}") + self._data = { + "last_soak": None, + "total_count": 0, + "history": [] + } + + def save_data(self) -> None: + """Save soak data to JSON file.""" + try: + with open(self.data_file, 'w') as f: + json.dump(self._data, f, indent=2, default=str) + logger.debug("Soak data saved successfully") + except Exception as e: + logger.error(f"Failed to save soak data: {e}") + + def record_soak( + self, + user_id: int, + username: str, + display_name: str, + channel_id: int, + message_id: int + ) -> None: + """ + Record a new soak mention. + + Args: + user_id: Discord user ID who mentioned soak + username: Discord username + display_name: Discord display name + channel_id: Channel where soak was mentioned + message_id: Message ID containing the mention + """ + soak_data = { + "timestamp": datetime.now(UTC).isoformat(), + "user_id": str(user_id), + "username": username, + "display_name": display_name, + "channel_id": str(channel_id), + "message_id": str(message_id) + } + + # Update last_soak + self._data["last_soak"] = soak_data + + # Increment counter + self._data["total_count"] = self._data.get("total_count", 0) + 1 + + # Add to history (newest first) + history = self._data.get("history", []) + history.insert(0, soak_data) + + # Optional: Limit history to last 1000 entries to prevent file bloat + if len(history) > 1000: + history = history[:1000] + + self._data["history"] = history + + self.save_data() + + logger.info(f"Recorded soak by {username} (ID: {user_id}) in channel {channel_id}") + + def get_last_soak(self) -> Optional[Dict[str, Any]]: + """ + Get the most recent soak data. + + Returns: + Dictionary with soak data, or None if no soaks recorded + """ + return self._data.get("last_soak") + + def get_time_since_last_soak(self) -> Optional[timedelta]: + """ + Calculate time elapsed since the last soak mention. + + Returns: + timedelta object, or None if no previous soaks + """ + last_soak = self.get_last_soak() + if not last_soak: + return None + + try: + # Parse ISO format timestamp + last_timestamp_str = last_soak["timestamp"] + if last_timestamp_str.endswith('Z'): + last_timestamp_str = last_timestamp_str[:-1] + '+00:00' + + last_timestamp = datetime.fromisoformat(last_timestamp_str.replace('Z', '+00:00')) + + # Ensure both times are timezone-aware + if last_timestamp.tzinfo is None: + last_timestamp = last_timestamp.replace(tzinfo=UTC) + + current_time = datetime.now(UTC) + time_elapsed = current_time - last_timestamp + + return time_elapsed + + except (ValueError, TypeError, KeyError) as e: + logger.warning(f"Invalid timestamp in last soak data: {e}") + return None + + def get_soak_count(self) -> int: + """ + Get the total number of soak mentions. + + Returns: + Total count across all time + """ + return self._data.get("total_count", 0) + + def get_history(self, limit: int = 100) -> List[Dict[str, Any]]: + """ + Get recent soak mention history. + + Args: + limit: Maximum number of entries to return + + Returns: + List of soak data dictionaries (newest first) + """ + history = self._data.get("history", []) + return history[:limit] diff --git a/tests/test_commands_soak.py b/tests/test_commands_soak.py new file mode 100644 index 0000000..5bef854 --- /dev/null +++ b/tests/test_commands_soak.py @@ -0,0 +1,354 @@ +""" +Unit tests for Soak Easter Egg functionality. + +Tests cover: +- Giphy service (tier determination, phrase selection, GIF fetching) +- Tracker (JSON persistence, soak recording, time calculations) +- Message listener (detection logic) +- Info command (response formatting) +""" +import pytest +import json +import re +from datetime import datetime, timedelta, UTC +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch, mock_open +from aioresponses import aioresponses + +# Import modules to test +from commands.soak.giphy_service import ( + get_tier_for_seconds, + get_random_phrase_for_tier, + get_tier_description, + get_disappointment_gif, + DISAPPOINTMENT_TIERS +) +from commands.soak.tracker import SoakTracker +from commands.soak.listener import SOAK_PATTERN + + +class TestGiphyService: + """Tests for Giphy service functionality.""" + + def test_tier_determination_first_ever(self): + """Test tier determination for first ever soak.""" + tier = get_tier_for_seconds(None) + assert tier == 'first_ever' + + def test_tier_determination_maximum_disappointment(self): + """Test tier 1: 0-30 minutes (maximum disappointment).""" + # 15 minutes + tier = get_tier_for_seconds(900) + assert tier == 'tier_1' + + # 30 minutes (boundary) + tier = get_tier_for_seconds(1800) + assert tier == 'tier_1' + + def test_tier_determination_severe_disappointment(self): + """Test tier 2: 30min-2hrs (severe disappointment).""" + # 1 hour + tier = get_tier_for_seconds(3600) + assert tier == 'tier_2' + + # 2 hours (boundary) + tier = get_tier_for_seconds(7200) + assert tier == 'tier_2' + + def test_tier_determination_strong_disappointment(self): + """Test tier 3: 2-6 hours (strong disappointment).""" + # 4 hours + tier = get_tier_for_seconds(14400) + assert tier == 'tier_3' + + # 6 hours (boundary) + tier = get_tier_for_seconds(21600) + assert tier == 'tier_3' + + def test_tier_determination_moderate_disappointment(self): + """Test tier 4: 6-24 hours (moderate disappointment).""" + # 12 hours + tier = get_tier_for_seconds(43200) + assert tier == 'tier_4' + + # 24 hours (boundary) + tier = get_tier_for_seconds(86400) + assert tier == 'tier_4' + + def test_tier_determination_mild_disappointment(self): + """Test tier 5: 1-7 days (mild disappointment).""" + # 3 days + tier = get_tier_for_seconds(259200) + assert tier == 'tier_5' + + # 7 days (boundary) + tier = get_tier_for_seconds(604800) + assert tier == 'tier_5' + + def test_tier_determination_minimal_disappointment(self): + """Test tier 6: 7+ days (minimal disappointment).""" + # 10 days + tier = get_tier_for_seconds(864000) + assert tier == 'tier_6' + + # 30 days + tier = get_tier_for_seconds(2592000) + assert tier == 'tier_6' + + def test_random_phrase_selection(self): + """Test that random phrase selection returns valid phrases.""" + for tier_key in DISAPPOINTMENT_TIERS.keys(): + phrase = get_random_phrase_for_tier(tier_key) + assert phrase in DISAPPOINTMENT_TIERS[tier_key]['phrases'] + + def test_tier_description_retrieval(self): + """Test tier description retrieval.""" + assert get_tier_description('tier_1') == "Maximum Disappointment" + assert get_tier_description('first_ever') == "The Beginning" + + @pytest.mark.asyncio + async def test_get_disappointment_gif_success(self): + """Test successful GIF fetch from Giphy API.""" + with aioresponses() as m: + # Mock successful Giphy response + m.get( + re.compile(r'https://api\.giphy\.com/v1/gifs/translate\?.*'), + payload={ + 'data': { + 'url': 'https://giphy.com/gifs/test123', + 'title': 'Disappointed Reaction' + } + }, + status=200 + ) + + gif_url = await get_disappointment_gif('tier_1') + assert gif_url == 'https://giphy.com/gifs/test123' + + @pytest.mark.asyncio + async def test_get_disappointment_gif_filters_trump(self): + """Test that Trump GIFs are filtered out.""" + with aioresponses() as m: + # First response is Trump GIF (should be filtered) + # Second response is acceptable + m.get( + re.compile(r'https://api\.giphy\.com/v1/gifs/translate\?.*'), + payload={ + 'data': { + 'url': 'https://giphy.com/gifs/trump123', + 'title': 'Donald Trump Disappointed' + } + }, + status=200 + ) + m.get( + re.compile(r'https://api\.giphy\.com/v1/gifs/translate\?.*'), + payload={ + 'data': { + 'url': 'https://giphy.com/gifs/good456', + 'title': 'Disappointed Reaction' + } + }, + status=200 + ) + + gif_url = await get_disappointment_gif('tier_1') + assert gif_url == 'https://giphy.com/gifs/good456' + + @pytest.mark.asyncio + async def test_get_disappointment_gif_api_failure(self): + """Test graceful handling of Giphy API failures.""" + with aioresponses() as m: + # Mock API failure for all requests + m.get( + re.compile(r'https://api\.giphy\.com/v1/gifs/translate\?.*'), + status=500, + repeat=True + ) + + gif_url = await get_disappointment_gif('tier_1') + assert gif_url is None + + +class TestSoakTracker: + """Tests for SoakTracker functionality.""" + + @pytest.fixture + def temp_tracker_file(self, tmp_path): + """Create a temporary tracker file path.""" + return str(tmp_path / "test_soak_data.json") + + def test_tracker_initialization_new_file(self, temp_tracker_file): + """Test tracker initialization with no existing file.""" + tracker = SoakTracker(temp_tracker_file) + + assert tracker.get_soak_count() == 0 + assert tracker.get_last_soak() is None + assert tracker.get_history() == [] + + def test_tracker_initialization_existing_file(self, temp_tracker_file): + """Test tracker initialization with existing data.""" + # Create existing data + existing_data = { + "last_soak": { + "timestamp": "2025-01-01T12:00:00+00:00", + "user_id": "123", + "username": "testuser", + "display_name": "Test User", + "channel_id": "456", + "message_id": "789" + }, + "total_count": 5, + "history": [] + } + + with open(temp_tracker_file, 'w') as f: + json.dump(existing_data, f) + + tracker = SoakTracker(temp_tracker_file) + + assert tracker.get_soak_count() == 5 + assert tracker.get_last_soak() is not None + + def test_record_soak(self, temp_tracker_file): + """Test recording a soak mention.""" + tracker = SoakTracker(temp_tracker_file) + + tracker.record_soak( + user_id=123456, + username="testuser", + display_name="Test User", + channel_id=789012, + message_id=345678 + ) + + assert tracker.get_soak_count() == 1 + + last_soak = tracker.get_last_soak() + assert last_soak is not None + assert last_soak['user_id'] == '123456' + assert last_soak['username'] == 'testuser' + + def test_record_multiple_soaks(self, temp_tracker_file): + """Test recording multiple soaks maintains history.""" + tracker = SoakTracker(temp_tracker_file) + + # Record 3 soaks + for i in range(3): + tracker.record_soak( + user_id=i, + username=f"user{i}", + display_name=f"User {i}", + channel_id=100, + message_id=200 + i + ) + + assert tracker.get_soak_count() == 3 + + history = tracker.get_history() + assert len(history) == 3 + # History should be newest first + assert history[0]['user_id'] == '2' + assert history[2]['user_id'] == '0' + + def test_get_time_since_last_soak(self, temp_tracker_file): + """Test time calculation since last soak.""" + tracker = SoakTracker(temp_tracker_file) + + # No previous soak + assert tracker.get_time_since_last_soak() is None + + # Record a soak + tracker.record_soak( + user_id=123, + username="test", + display_name="Test", + channel_id=456, + message_id=789 + ) + + # Time since should be very small (just recorded) + time_since = tracker.get_time_since_last_soak() + assert time_since is not None + assert time_since.total_seconds() < 5 # Should be < 5 seconds + + def test_history_limit(self, temp_tracker_file): + """Test that history is limited to prevent file bloat.""" + tracker = SoakTracker(temp_tracker_file) + + # Record 1100 soaks (exceeds 1000 limit) + for i in range(1100): + tracker.record_soak( + user_id=i, + username=f"user{i}", + display_name=f"User {i}", + channel_id=100, + message_id=200 + i + ) + + history = tracker.get_history(limit=9999) + # Should be capped at 1000 + assert len(history) == 1000 + + +class TestMessageListener: + """Tests for message listener detection logic.""" + + def test_soak_pattern_exact_match(self): + """Test regex pattern matches exact 'soak'.""" + assert SOAK_PATTERN.search("soak") is not None + + def test_soak_pattern_case_insensitive(self): + """Test case insensitivity.""" + assert SOAK_PATTERN.search("SOAK") is not None + assert SOAK_PATTERN.search("Soak") is not None + assert SOAK_PATTERN.search("SoAk") is not None + + def test_soak_pattern_variations(self): + """Test pattern matches all variations.""" + assert SOAK_PATTERN.search("soaking") is not None + assert SOAK_PATTERN.search("soaked") is not None + assert SOAK_PATTERN.search("soaker") is not None + + def test_soak_pattern_word_boundaries(self): + """Test that pattern requires word boundaries.""" + # Should match + assert SOAK_PATTERN.search("I was soaking yesterday") is not None + assert SOAK_PATTERN.search("Let's go soak in the pool") is not None + + # Should NOT match (part of another word) + # Note: These examples don't exist in common English, but testing boundary logic + assert SOAK_PATTERN.search("cloaked") is None # 'oak' inside word + + def test_soak_pattern_in_sentence(self): + """Test pattern detection in full sentences.""" + assert SOAK_PATTERN.search("We went soaking last night") is not None + assert SOAK_PATTERN.search("The clothes are soaked") is not None + assert SOAK_PATTERN.search("Pass me the soaker") is not None + + +class TestInfoCommand: + """Tests for /lastsoak info command.""" + + # Note: Full command testing requires discord.py test utilities + # These tests focus on the logic components + + def test_timestamp_formatting_logic(self): + """Test Unix timestamp calculation for Discord formatting.""" + # Create a known timestamp + dt = datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC) + unix_timestamp = int(dt.timestamp()) + + # Verify timestamp is a valid Unix timestamp (positive integer) + # The exact value depends on timezone, but should be reasonable + assert unix_timestamp > 1700000000 # After 2023 + assert unix_timestamp < 2000000000 # Before 2033 + + def test_jump_url_formatting(self): + """Test Discord message jump URL formatting.""" + guild_id = 123456789 + channel_id = 987654321 + message_id = 111222333 + + expected_url = f"https://discord.com/channels/{guild_id}/{channel_id}/{message_id}" + assert expected_url == "https://discord.com/channels/123456789/987654321/111222333"