major-domo-v2/commands/soak/giphy_service.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

196 lines
5.6 KiB
Python

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