major-domo-v2/commands/soak/tracker.py
Cal Corum 68c30e565b CLAUDE: Implement soak easter egg with disappointment GIFs and tracking
Add comprehensive "soaking" easter egg feature that detects mentions
and responds with GIFs showing escalating disappointment based on
recency (reversed from legacy - more recent = more disappointed).

Features:
- Detects "soak", "soaking", "soaked", "soaker" (case-insensitive)
- 7 disappointment tiers with 5 varied search phrases each
- Giphy API integration with Trump filter and fallback handling
- JSON-based persistence tracking all mentions with history
- /lastsoak command showing detailed information
- 25 comprehensive unit tests (all passing)

Architecture:
- commands/soak/giphy_service.py - Tiered GIF fetching
- commands/soak/tracker.py - JSON persistence with history
- commands/soak/listener.py - Message detection and response
- commands/soak/info.py - /lastsoak info command
- tests/test_commands_soak.py - Full test coverage

Uses existing Giphy API key from legacy implementation.
Zero new dependencies, follows established patterns.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 23:25:22 -05:00

177 lines
5.3 KiB
Python

"""
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]