major-domo-v2/services/giphy_service.py
Cal Corum 0c6f7c8ffe CLAUDE: Fix GroupCog interaction bug and GIF display issues
This commit addresses critical bugs in the injury command system and
establishes best practices for Discord command groups.

## Critical Fixes

### 1. GroupCog → app_commands.Group Migration
- **Problem**: `commands.GroupCog` has a duplicate interaction processing bug
  causing "404 Unknown interaction" errors when deferring responses
- **Root Cause**: GroupCog triggers command handler twice, consuming the
  interaction token before the second execution can respond
- **Solution**: Migrated InjuryCog to InjuryGroup using `app_commands.Group`
  pattern (same as ChartManageGroup and ChartCategoryGroup)
- **Result**: Reliable command execution, no more 404 errors

### 2. GiphyService GIF URL Fix
- **Problem**: Giphy service returned web page URLs (https://giphy.com/gifs/...)
  instead of direct image URLs, preventing Discord embed display
- **Root Cause**: Code accessed `data.url` instead of `data.images.original.url`
- **Solution**: Updated both `get_disappointment_gif()` and `get_gif()` methods
  to use correct API response path for embeddable GIF URLs
- **Result**: GIFs now display correctly in Discord embeds

## Documentation

### Command Groups Best Practices (commands/README.md)
Added comprehensive section documenting:
- **Critical Warning**: Never use `commands.GroupCog` - use `app_commands.Group`
- **Technical Explanation**: Why GroupCog fails (duplicate execution bug)
- **Migration Guide**: Step-by-step conversion from GroupCog to Group
- **Comparison Table**: Key differences between the two approaches
- **Working Examples**: References to ChartManageGroup, InjuryGroup patterns

## Architecture Changes

### Injury Commands (`commands/injuries/`)
- Converted from `commands.GroupCog` to `app_commands.Group`
- Registration via `bot.tree.add_command()` instead of `bot.add_cog()`
- Removed workarounds for GroupCog duplicate interaction issues
- Clean defer/response pattern with `@logged_command` decorator

### GiphyService (`services/giphy_service.py`)
- Centralized from `commands/soak/giphy_service.py`
- Now returns direct GIF image URLs for Discord embeds
- Maintains Trump GIF filtering (legacy behavior)
- Added gif_url to log output for debugging

### Configuration (`config.py`)
- Added `giphy_api_key` and `giphy_translate_url` settings
- Environment variable support via `GIPHY_API_KEY`
- Default values provided for out-of-box functionality

## Files Changed
- commands/injuries/: New InjuryGroup with app_commands.Group pattern
- services/giphy_service.py: Centralized service with GIF URL fix
- commands/soak/giphy_service.py: Backwards compatibility wrapper
- commands/README.md: Command groups best practices documentation
- config.py: Giphy configuration settings
- services/__init__.py: GiphyService exports

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 22:15:42 -05:00

285 lines
9.8 KiB
Python

"""
Giphy Service for Discord Bot v2.0
Provides async interface to Giphy API with disappointment-based search phrases.
Used for Easter egg features like the soak command.
"""
import random
from typing import List, Optional
import aiohttp
from utils.logging import get_contextual_logger
from config import get_config
from exceptions import APIException
# 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"
}
}
class GiphyService:
"""Service for fetching GIFs from Giphy API based on disappointment tiers."""
def __init__(self):
"""Initialize Giphy service with configuration."""
self.config = get_config()
self.api_key = self.config.giphy_api_key
self.translate_url = self.config.giphy_translate_url
self.logger = get_contextual_logger(f'{__name__}.GiphyService')
def get_tier_for_seconds(self, 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(self, 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
Raises:
ValueError: If tier_key is invalid
"""
if tier_key not in DISAPPOINTMENT_TIERS:
raise ValueError(f"Invalid tier key: {tier_key}")
phrases = DISAPPOINTMENT_TIERS[tier_key]['phrases']
return random.choice(phrases)
def get_tier_description(self, tier_key: str) -> str:
"""
Get the human-readable description for a tier.
Args:
tier_key: Tier identifier
Returns:
Description string
Raises:
ValueError: If tier_key is invalid
"""
if tier_key not in DISAPPOINTMENT_TIERS:
raise ValueError(f"Invalid tier key: {tier_key}")
return DISAPPOINTMENT_TIERS[tier_key]['description']
async def get_disappointment_gif(self, tier_key: str) -> 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
Raises:
ValueError: If tier_key is invalid
APIException: If all GIF fetch attempts fail
"""
if tier_key not in DISAPPOINTMENT_TIERS:
raise ValueError(f"Invalid tier key: {tier_key}")
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"{self.translate_url}?s={phrase}&api_key={self.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:
self.logger.debug(f"Filtered out Trump GIF for phrase: {phrase}")
continue
# Get the actual GIF image URL, not the web page URL
gif_url = data.get('data', {}).get('images', {}).get('original', {}).get('url')
if gif_url:
self.logger.info(f"Successfully fetched GIF for phrase: {phrase}", gif_url=gif_url)
return gif_url
else:
self.logger.warning(f"No GIF URL in response for phrase: {phrase}")
else:
self.logger.warning(f"Giphy API returned status {resp.status} for phrase: {phrase}")
except aiohttp.ClientError as e:
self.logger.error(f"HTTP error fetching GIF for phrase '{phrase}': {e}")
except Exception as e:
self.logger.error(f"Unexpected error fetching GIF for phrase '{phrase}': {e}")
# All phrases failed
error_msg = f"Failed to fetch any GIF for tier: {tier_key}"
self.logger.error(error_msg)
raise APIException(error_msg)
async def get_gif(self, phrase: Optional[str] = None, phrase_options: Optional[List[str]] = None) -> str:
"""
Fetch a GIF from Giphy based on a phrase or list of phrase options.
Args:
phrase: Specific search phrase to use
phrase_options: List of phrases to randomly choose from
Returns:
GIF URL string
Raises:
ValueError: If neither phrase nor phrase_options is provided
APIException: If all GIF fetch attempts fail
"""
if phrase is None and phrase_options is None:
raise ValueError('To get a gif, one of `phrase` or `phrase_options` must be provided')
search_phrase = 'send help'
if phrase is not None:
search_phrase = phrase
elif phrase_options is not None:
search_phrase = random.choice(phrase_options)
async with aiohttp.ClientSession() as session:
attempts = 0
while attempts < 3:
attempts += 1
try:
url = f"{self.translate_url}?s={search_phrase}&api_key={self.api_key}"
async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as resp:
if resp.status != 200:
self.logger.warning(f"Giphy API returned status {resp.status} for phrase: {search_phrase}")
continue
data = await resp.json()
# Filter out Trump GIFs (legacy behavior)
gif_title = data.get('data', {}).get('title', '').lower()
if 'trump' in gif_title:
self.logger.debug(f"Filtered out Trump GIF for phrase: {search_phrase}")
continue
# Get the actual GIF image URL, not the web page URL
gif_url = data.get('data', {}).get('images', {}).get('original', {}).get('url')
if gif_url:
self.logger.info(f"Successfully fetched GIF for phrase: {search_phrase}", gif_url=gif_url)
return gif_url
else:
self.logger.warning(f"No GIF URL in response for phrase: {search_phrase}")
except aiohttp.ClientError as e:
self.logger.error(f"HTTP error fetching GIF for phrase '{search_phrase}': {e}")
except Exception as e:
self.logger.error(f"Unexpected error fetching GIF for phrase '{search_phrase}': {e}")
# All attempts failed
error_msg = f"Failed to fetch any GIF for phrase: {search_phrase}"
self.logger.error(error_msg)
raise APIException(error_msg)