From bdd3a0fd41d398e5bc72c6312ac95c7c01b23b2a Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 12 Dec 2025 21:15:40 -0800 Subject: [PATCH] Enhance SportsCommand with improved game fetching logic - Added mappings for Professional Women's Hockey League (PWHL) teams with placeholders for team IDs. - Updated city mappings to include PWHL teams for relevant cities. - Enhanced game fetching logic to prioritize live games, recent past games, and upcoming games within specified timeframes. - Introduced methods for fetching and formatting team schedules, improving user experience for schedule queries. - Improved handling of live event data to ensure real-time score updates for ongoing games. --- modules/commands/sports_command.py | 467 ++++++++++++++++++++++++----- 1 file changed, 396 insertions(+), 71 deletions(-) diff --git a/modules/commands/sports_command.py b/modules/commands/sports_command.py index 0896745..70b2f4e 100644 --- a/modules/commands/sports_command.py +++ b/modules/commands/sports_command.py @@ -82,6 +82,18 @@ class SportsCommand(BaseCommand): '8': 'MIN-W', # Minnesota Lynx (Women's) '11': 'PHX-W', # Phoenix Mercury (Women's) '16': 'WSH-W', # Washington Mystics (Women's) + # PWHL teams - use custom abbreviations to distinguish from NHL + # NOTE: Team IDs need to be verified using test_scripts/find_espn_team_id.py hockey pwhl + # Once verified, uncomment and replace 'VERIFY_TEAM_ID' with the actual ESPN team ID + # Format: 'ACTUAL_TEAM_ID': 'ABBREV-W', # Team Name (Women's) + # Example: '123456': 'BOS-W', # Boston (Women's) + # 'VERIFY_BOS': 'BOS-W', # Boston (Women's) - verify team_id + # 'VERIFY_MIN': 'MIN-W', # Minnesota (Women's) - verify team_id + # 'VERIFY_MTL': 'MTL-W', # Montreal (Women's) - verify team_id + # 'VERIFY_NY': 'NY-W', # New York (Women's) - verify team_id + # 'VERIFY_OTT': 'OTT-W', # Ottawa (Women's) - verify team_id + # 'VERIFY_TOR': 'TOR-W', # Toronto (Women's) - verify team_id + # 'VERIFY_SEA': 'SEA-W', # Seattle Torrent (Women's) - verify team_id } # Team mappings for common searches @@ -118,6 +130,8 @@ class SportsCommand(BaseCommand): 'ari': {'sport': 'football', 'league': 'nfl', 'team_id': '22'}, 'chargers': {'sport': 'football', 'league': 'nfl', 'team_id': '24'}, 'lac': {'sport': 'football', 'league': 'nfl', 'team_id': '24'}, + 'la chargers': {'sport': 'football', 'league': 'nfl', 'team_id': '24'}, + 'los angeles chargers': {'sport': 'football', 'league': 'nfl', 'team_id': '24'}, 'chiefs': {'sport': 'football', 'league': 'nfl', 'team_id': '12'}, 'kansas city': {'sport': 'football', 'league': 'nfl', 'team_id': '12'}, 'kc': {'sport': 'football', 'league': 'nfl', 'team_id': '12'}, @@ -143,6 +157,7 @@ class SportsCommand(BaseCommand): 'nyg': {'sport': 'football', 'league': 'nfl', 'team_id': '19'}, 'jaguars': {'sport': 'football', 'league': 'nfl', 'team_id': '30'}, 'jax': {'sport': 'football', 'league': 'nfl', 'team_id': '30'}, + 'jacksonville': {'sport': 'football', 'league': 'nfl', 'team_id': '30'}, 'jets': {'sport': 'football', 'league': 'nfl', 'team_id': '20'}, 'nyj': {'sport': 'football', 'league': 'nfl', 'team_id': '20'}, 'lions': {'sport': 'football', 'league': 'nfl', 'team_id': '8'}, @@ -162,6 +177,8 @@ class SportsCommand(BaseCommand): 'lv': {'sport': 'football', 'league': 'nfl', 'team_id': '13'}, 'rams': {'sport': 'football', 'league': 'nfl', 'team_id': '14'}, 'lar': {'sport': 'football', 'league': 'nfl', 'team_id': '14'}, + 'la rams': {'sport': 'football', 'league': 'nfl', 'team_id': '14'}, + 'los angeles rams': {'sport': 'football', 'league': 'nfl', 'team_id': '14'}, 'ravens': {'sport': 'football', 'league': 'nfl', 'team_id': '33'}, 'baltimore': {'sport': 'football', 'league': 'nfl', 'team_id': '33'}, 'bal': {'sport': 'football', 'league': 'nfl', 'team_id': '33'}, @@ -309,11 +326,117 @@ class SportsCommand(BaseCommand): 'mystics': {'sport': 'basketball', 'league': 'wnba', 'team_id': '16'}, 'washington mystics': {'sport': 'basketball', 'league': 'wnba', 'team_id': '16'}, - # NHL Teams (limited data available from API) + # NHL Teams + 'ducks': {'sport': 'hockey', 'league': 'nhl', 'team_id': '25'}, + 'anaheim': {'sport': 'hockey', 'league': 'nhl', 'team_id': '25'}, + 'ana': {'sport': 'hockey', 'league': 'nhl', 'team_id': '25'}, + 'bruins': {'sport': 'hockey', 'league': 'nhl', 'team_id': '1'}, + 'boston bruins': {'sport': 'hockey', 'league': 'nhl', 'team_id': '1'}, + 'bos': {'sport': 'hockey', 'league': 'nhl', 'team_id': '1'}, + 'sabres': {'sport': 'hockey', 'league': 'nhl', 'team_id': '2'}, + 'buffalo': {'sport': 'hockey', 'league': 'nhl', 'team_id': '2'}, + 'buf': {'sport': 'hockey', 'league': 'nhl', 'team_id': '2'}, + 'flames': {'sport': 'hockey', 'league': 'nhl', 'team_id': '3'}, + 'calgary': {'sport': 'hockey', 'league': 'nhl', 'team_id': '3'}, + 'cgy': {'sport': 'hockey', 'league': 'nhl', 'team_id': '3'}, + 'hurricanes': {'sport': 'hockey', 'league': 'nhl', 'team_id': '7'}, + 'carolina': {'sport': 'hockey', 'league': 'nhl', 'team_id': '7'}, + 'car': {'sport': 'hockey', 'league': 'nhl', 'team_id': '7'}, + 'blackhawks': {'sport': 'hockey', 'league': 'nhl', 'team_id': '4'}, + 'chicago blackhawks': {'sport': 'hockey', 'league': 'nhl', 'team_id': '4'}, + 'chi': {'sport': 'hockey', 'league': 'nhl', 'team_id': '4'}, + 'avalanche': {'sport': 'hockey', 'league': 'nhl', 'team_id': '17'}, + 'colorado': {'sport': 'hockey', 'league': 'nhl', 'team_id': '17'}, + 'col': {'sport': 'hockey', 'league': 'nhl', 'team_id': '17'}, + 'blue jackets': {'sport': 'hockey', 'league': 'nhl', 'team_id': '29'}, + 'columbus': {'sport': 'hockey', 'league': 'nhl', 'team_id': '29'}, + 'cbj': {'sport': 'hockey', 'league': 'nhl', 'team_id': '29'}, + 'stars': {'sport': 'hockey', 'league': 'nhl', 'team_id': '9'}, + 'dallas': {'sport': 'hockey', 'league': 'nhl', 'team_id': '9'}, + 'dal': {'sport': 'hockey', 'league': 'nhl', 'team_id': '9'}, + 'red wings': {'sport': 'hockey', 'league': 'nhl', 'team_id': '5'}, + 'detroit': {'sport': 'hockey', 'league': 'nhl', 'team_id': '5'}, + 'det': {'sport': 'hockey', 'league': 'nhl', 'team_id': '5'}, + 'oilers': {'sport': 'hockey', 'league': 'nhl', 'team_id': '6'}, + 'edmonton': {'sport': 'hockey', 'league': 'nhl', 'team_id': '6'}, + 'edm': {'sport': 'hockey', 'league': 'nhl', 'team_id': '6'}, + 'panthers': {'sport': 'hockey', 'league': 'nhl', 'team_id': '26'}, + 'florida': {'sport': 'hockey', 'league': 'nhl', 'team_id': '26'}, + 'fla': {'sport': 'hockey', 'league': 'nhl', 'team_id': '26'}, + 'kings': {'sport': 'hockey', 'league': 'nhl', 'team_id': '8'}, + 'los angeles': {'sport': 'hockey', 'league': 'nhl', 'team_id': '8'}, + 'la': {'sport': 'hockey', 'league': 'nhl', 'team_id': '8'}, + 'wild': {'sport': 'hockey', 'league': 'nhl', 'team_id': '30'}, + 'minnesota': {'sport': 'hockey', 'league': 'nhl', 'team_id': '30'}, + 'min': {'sport': 'hockey', 'league': 'nhl', 'team_id': '30'}, + 'canadiens': {'sport': 'hockey', 'league': 'nhl', 'team_id': '10'}, + 'montreal': {'sport': 'hockey', 'league': 'nhl', 'team_id': '10'}, + 'mtl': {'sport': 'hockey', 'league': 'nhl', 'team_id': '10'}, + 'predators': {'sport': 'hockey', 'league': 'nhl', 'team_id': '27'}, + 'nashville': {'sport': 'hockey', 'league': 'nhl', 'team_id': '27'}, + 'nsh': {'sport': 'hockey', 'league': 'nhl', 'team_id': '27'}, + 'devils': {'sport': 'hockey', 'league': 'nhl', 'team_id': '11'}, + 'new jersey': {'sport': 'hockey', 'league': 'nhl', 'team_id': '11'}, + 'nj': {'sport': 'hockey', 'league': 'nhl', 'team_id': '11'}, + 'islanders': {'sport': 'hockey', 'league': 'nhl', 'team_id': '12'}, + 'new york islanders': {'sport': 'hockey', 'league': 'nhl', 'team_id': '12'}, + 'nyi': {'sport': 'hockey', 'league': 'nhl', 'team_id': '12'}, + 'rangers': {'sport': 'hockey', 'league': 'nhl', 'team_id': '13'}, + 'new york rangers': {'sport': 'hockey', 'league': 'nhl', 'team_id': '13'}, + 'nyr': {'sport': 'hockey', 'league': 'nhl', 'team_id': '13'}, + 'senators': {'sport': 'hockey', 'league': 'nhl', 'team_id': '14'}, + 'ottawa': {'sport': 'hockey', 'league': 'nhl', 'team_id': '14'}, + 'ott': {'sport': 'hockey', 'league': 'nhl', 'team_id': '14'}, + 'flyers': {'sport': 'hockey', 'league': 'nhl', 'team_id': '15'}, + 'philadelphia': {'sport': 'hockey', 'league': 'nhl', 'team_id': '15'}, + 'phi': {'sport': 'hockey', 'league': 'nhl', 'team_id': '15'}, + 'penguins': {'sport': 'hockey', 'league': 'nhl', 'team_id': '16'}, + 'pittsburgh': {'sport': 'hockey', 'league': 'nhl', 'team_id': '16'}, + 'pit': {'sport': 'hockey', 'league': 'nhl', 'team_id': '16'}, + 'sharks': {'sport': 'hockey', 'league': 'nhl', 'team_id': '18'}, + 'san jose': {'sport': 'hockey', 'league': 'nhl', 'team_id': '18'}, + 'sj': {'sport': 'hockey', 'league': 'nhl', 'team_id': '18'}, 'kraken': {'sport': 'hockey', 'league': 'nhl', 'team_id': '124292'}, 'seattle kraken': {'sport': 'hockey', 'league': 'nhl', 'team_id': '124292'}, + 'seattle': {'sport': 'hockey', 'league': 'nhl', 'team_id': '124292'}, 'blues': {'sport': 'hockey', 'league': 'nhl', 'team_id': '19'}, - 'stars': {'sport': 'hockey', 'league': 'nhl', 'team_id': '9'}, + 'st louis': {'sport': 'hockey', 'league': 'nhl', 'team_id': '19'}, + 'stl': {'sport': 'hockey', 'league': 'nhl', 'team_id': '19'}, + 'lightning': {'sport': 'hockey', 'league': 'nhl', 'team_id': '20'}, + 'tampa bay': {'sport': 'hockey', 'league': 'nhl', 'team_id': '20'}, + 'tb': {'sport': 'hockey', 'league': 'nhl', 'team_id': '20'}, + 'maple leafs': {'sport': 'hockey', 'league': 'nhl', 'team_id': '21'}, + 'toronto': {'sport': 'hockey', 'league': 'nhl', 'team_id': '21'}, + 'tor': {'sport': 'hockey', 'league': 'nhl', 'team_id': '21'}, + 'mammoth': {'sport': 'hockey', 'league': 'nhl', 'team_id': '129764'}, + 'utah': {'sport': 'hockey', 'league': 'nhl', 'team_id': '129764'}, + 'utah mammoth': {'sport': 'hockey', 'league': 'nhl', 'team_id': '129764'}, + 'canucks': {'sport': 'hockey', 'league': 'nhl', 'team_id': '22'}, + 'vancouver': {'sport': 'hockey', 'league': 'nhl', 'team_id': '22'}, + 'van': {'sport': 'hockey', 'league': 'nhl', 'team_id': '22'}, + 'golden knights': {'sport': 'hockey', 'league': 'nhl', 'team_id': '37'}, + 'vegas': {'sport': 'hockey', 'league': 'nhl', 'team_id': '37'}, + 'vgk': {'sport': 'hockey', 'league': 'nhl', 'team_id': '37'}, + 'capitals': {'sport': 'hockey', 'league': 'nhl', 'team_id': '23'}, + 'washington': {'sport': 'hockey', 'league': 'nhl', 'team_id': '23'}, + 'wsh': {'sport': 'hockey', 'league': 'nhl', 'team_id': '23'}, + 'jets': {'sport': 'hockey', 'league': 'nhl', 'team_id': '28'}, + 'winnipeg': {'sport': 'hockey', 'league': 'nhl', 'team_id': '28'}, + 'wpg': {'sport': 'hockey', 'league': 'nhl', 'team_id': '28'}, + + # PWHL Teams (Professional Women's Hockey League) + # NOTE: As of now, PWHL data may not be available in ESPN's API yet. + # ESPN may not have added PWHL teams to their API endpoints. + # Team IDs need to be verified using test_scripts/find_espn_team_id.py hockey pwhl + # Once ESPN adds PWHL support and team IDs are verified, uncomment and update the team_id values below + # 'torrent': {'sport': 'hockey', 'league': 'pwhl', 'team_id': 'VERIFY_TEAM_ID'}, + # 'seattle torrent': {'sport': 'hockey', 'league': 'pwhl', 'team_id': 'VERIFY_TEAM_ID'}, + # 'boston pwhl': {'sport': 'hockey', 'league': 'pwhl', 'team_id': 'VERIFY_TEAM_ID'}, + # 'minnesota pwhl': {'sport': 'hockey', 'league': 'pwhl', 'team_id': 'VERIFY_TEAM_ID'}, + # 'montreal pwhl': {'sport': 'hockey', 'league': 'pwhl', 'team_id': 'VERIFY_TEAM_ID'}, + # 'new york pwhl': {'sport': 'hockey', 'league': 'pwhl', 'team_id': 'VERIFY_TEAM_ID'}, + # 'ottawa pwhl': {'sport': 'hockey', 'league': 'pwhl', 'team_id': 'VERIFY_TEAM_ID'}, + # 'toronto pwhl': {'sport': 'hockey', 'league': 'pwhl', 'team_id': 'VERIFY_TEAM_ID'}, # MLS Teams 'sounders': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9726'}, @@ -455,7 +578,8 @@ class SportsCommand(BaseCommand): """Check if the league is a women's league""" womens_leagues = { ('basketball', 'wnba'), - ('soccer', 'usa.nwsl') + ('soccer', 'usa.nwsl'), + ('hockey', 'pwhl') } return (sport, league) in womens_leagues @@ -608,6 +732,7 @@ class SportsCommand(BaseCommand): # Extract team name if provided parts = content.split() if len(parts) > 1: + # Join all parts after 'sports' keyword, preserving "schedule" if present team_name = ' '.join(parts[1:]).lower() response = await self.get_team_scores(team_name) else: @@ -635,9 +760,10 @@ class SportsCommand(BaseCommand): try: team_info = self.TEAM_MAPPINGS.get(team) if team_info: - game_info = await self.fetch_team_game_data(team_info) - if game_info: - game_data.append(game_info) + # Get all relevant games for this team (live, past within 8 days, upcoming within 6 weeks) + games = await self.fetch_team_games(team_info) + if games: + game_data.extend(games) except Exception as e: self.logger.warning(f"Error fetching score for {team}: {e}") @@ -689,6 +815,10 @@ class SportsCommand(BaseCommand): 'nhl': {'sport': 'hockey', 'league': 'nhl'}, 'hockey': {'sport': 'hockey', 'league': 'nhl'}, + # PWHL + 'pwhl': {'sport': 'hockey', 'league': 'pwhl'}, + 'womens hockey': {'sport': 'hockey', 'league': 'pwhl'}, + # MLS 'mls': {'sport': 'soccer', 'league': 'usa.1'}, 'soccer': {'sport': 'soccer', 'league': 'usa.1'}, @@ -712,14 +842,14 @@ class SportsCommand(BaseCommand): # Define city mappings to team names city_mappings = { - 'seattle': ['seahawks', 'mariners', 'sounders', 'kraken', 'reign', 'storm'], - 'chicago': ['bears', 'cubs', 'white sox', 'fire', 'sky'], - 'new york': ['giants', 'jets', 'yankees', 'mets', 'knicks', 'nyc fc', 'red bulls', 'liberty'], - 'ny': ['giants', 'jets', 'yankees', 'mets', 'knicks', 'nyc fc', 'red bulls', 'liberty'], + 'seattle': ['seahawks', 'mariners', 'sounders', 'kraken', 'reign', 'storm', 'torrent'], + 'chicago': ['bears', 'cubs', 'white sox', 'fire', 'sky', 'blackhawks'], + 'new york': ['giants', 'jets', 'yankees', 'mets', 'knicks', 'nyc fc', 'red bulls', 'liberty', 'rangers', 'islanders'], # Add PWHL New York when team_id verified + 'ny': ['giants', 'jets', 'yankees', 'mets', 'knicks', 'nyc fc', 'red bulls', 'liberty', 'rangers', 'islanders'], # Add PWHL New York when team_id verified 'los angeles': ['rams', 'dodgers', 'lakers', 'la galaxy', 'lafc', 'sparks'], 'la': ['rams', 'dodgers', 'lakers', 'la galaxy', 'lafc', 'sparks'], 'miami': ['dolphins', 'marlins', 'heat', 'inter miami'], - 'boston': ['patriots', 'red sox', 'celtics', 'revolution'], + 'boston': ['patriots', 'red sox', 'celtics', 'revolution', 'bruins'], # Add PWHL Boston when team_id verified 'philadelphia': ['eagles', 'phillies', '76ers', 'union'], 'philadelphia': ['eagles', 'phillies', '76ers', 'union'], 'atlanta': ['falcons', 'braves', 'hawks', 'atlanta united', 'dream'], @@ -727,8 +857,8 @@ class SportsCommand(BaseCommand): 'dallas': ['cowboys', 'rangers', 'stars', 'fc dallas', 'wings'], 'denver': ['broncos', 'rockies', 'rapids'], 'detroit': ['lions', 'tigers', 'pistons'], - 'minnesota': ['vikings', 'twins', 'timberwolves', 'minnesota united', 'lynx'], - 'minneapolis': ['vikings', 'twins', 'timberwolves', 'minnesota united', 'lynx'], + 'minnesota': ['vikings', 'twins', 'timberwolves', 'minnesota united', 'lynx', 'wild'], # Add PWHL Minnesota when team_id verified + 'minneapolis': ['vikings', 'twins', 'timberwolves', 'minnesota united', 'lynx'], # Add PWHL Minnesota when team_id verified 'cleveland': ['browns', 'guardians', 'cavaliers'], 'cincinnati': ['bengals', 'reds', 'fc cincinnati'], 'pittsburgh': ['steelers', 'pirates', 'penguins'], @@ -773,16 +903,16 @@ class SportsCommand(BaseCommand): 'utah': ['jazz', 'real salt lake'], 'orlando': ['magic', 'orlando city'], 'orl': ['magic', 'orlando city'], - 'toronto': ['raptors', 'blue jays', 'toronto fc', 'maple leafs'], - 'tor': ['raptors', 'blue jays', 'toronto fc', 'maple leafs'], + 'toronto': ['raptors', 'blue jays', 'toronto fc', 'maple leafs'], # Add PWHL Toronto when team_id verified + 'tor': ['raptors', 'blue jays', 'toronto fc', 'maple leafs'], # Add PWHL Toronto when team_id verified 'vancouver': ['canucks', 'whitecaps'], 'van': ['canucks', 'whitecaps'], - 'montreal': ['canadiens', 'cf montreal'], - 'mtl': ['canadiens', 'cf montreal'], + 'montreal': ['canadiens', 'cf montreal'], # Add PWHL Montreal when team_id verified + 'mtl': ['canadiens', 'cf montreal'], # Add PWHL Montreal when team_id verified 'calgary': ['flames'], 'edmonton': ['oilers'], 'winnipeg': ['jets'], - 'ottawa': ['senators'], + 'ottawa': ['senators'], # Add PWHL Ottawa when team_id verified 'columbus': ['blue jackets', 'crew'], 'clb': ['blue jackets', 'crew'], 'st louis': ['blues', 'st louis city'], @@ -818,9 +948,10 @@ class SportsCommand(BaseCommand): game_data = [] for team_info in city_teams: try: - game_info = await self.fetch_team_game_data(team_info) - if game_info: - game_data.append(game_info) + # Get all relevant games for this team (live, past within 8 days, upcoming within 6 weeks) + games = await self.fetch_team_games(team_info) + if games: + game_data.extend(games) except Exception as e: self.logger.warning(f"Error fetching score for {team_info}: {e}") @@ -953,10 +1084,12 @@ class SportsCommand(BaseCommand): pass # Format based on game status - if status_name in ['STATUS_IN_PROGRESS', 'STATUS_FIRST_HALF', 'STATUS_SECOND_HALF']: + if status_name in ['STATUS_IN_PROGRESS', 'STATUS_FIRST_HALF', 'STATUS_SECOND_HALF', 'STATUS_END_PERIOD']: # Game is live - prioritize these (use negative timestamp) + # STATUS_END_PERIOD means a period just ended but game is still ongoing clock = status.get('displayClock', '') period = status.get('period', 0) + is_end_period = (status_name == 'STATUS_END_PERIOD') # Format period based on sport if sport == 'soccer': @@ -975,12 +1108,18 @@ class SportsCommand(BaseCommand): period_str = short_detail # e.g., "Top 14th", "Bottom 9th" else: period_str = f"{period}I" # Fallback to inning number only + if is_end_period: + period_str = f"End {period_str}" formatted = f"{away_name} {away_score}-{home_score} @{home_name} ({period_str})" elif sport == 'football': period_str = f"Q{period}" # Quarters + if is_end_period: + period_str = f"End {period_str}" formatted = f"{away_name} {away_score}-{home_score} @{home_name} ({clock} {period_str})" else: period_str = f"P{period}" # Generic periods + if is_end_period: + period_str = f"End {period_str}" formatted = f"{away_name} {away_score}-{home_score} @{home_name} ({clock} {period_str})" timestamp = -1 # Live games first @@ -1097,6 +1236,23 @@ class SportsCommand(BaseCommand): async def get_team_scores(self, team_name: str) -> str: """Get scores for a specific team or league""" + # Check if this is a schedule query (team_name ends with " schedule") + if team_name.endswith(' schedule'): + team_name_clean = team_name[:-9].strip() # Remove " schedule" + team_info = self.TEAM_MAPPINGS.get(team_name_clean) + if not team_info: + return self.translate('commands.sports.team_not_found', team=team_name_clean) + + try: + schedule_info = await self.fetch_team_schedule_formatted(team_info) + if schedule_info: + return schedule_info + else: + return self.translate('commands.sports.no_games_team', team=team_name_clean) + except Exception as e: + self.logger.error(f"Error fetching schedule for {team_name_clean}: {e}") + return self.translate('commands.sports.error_fetching_team', team=team_name_clean) + # Check if this is a league query league_info = self.get_league_info(team_name) if league_info: @@ -1173,8 +1329,163 @@ class SportsCommand(BaseCommand): Uses the team schedule endpoint which returns both past and upcoming games in a single API call. Returns games sorted by relevance: - Live games first - - Then upcoming games - - Then recent past games (most recent first) + - Last completed game (if within last 8 days) + - Next scheduled game (if known) + """ + try: + # Use team schedule endpoint - returns both past and upcoming games + url = f"{self.ESPN_BASE_URL}/{team_info['sport']}/{team_info['league']}/teams/{team_info['team_id']}/schedule" + + # Make API request + response = requests.get(url, timeout=self.url_timeout) + response.raise_for_status() + + data = response.json() + events = data.get('events', []) + + if not events: + return [] + + # Parse all games + all_games = [] + live_event_ids = [] # Track event IDs for live games + for event in events: + game_data = self.parse_game_event_with_timestamp(event, team_info['team_id'], team_info['sport'], team_info['league']) + if game_data: + all_games.append(game_data) + # If this is a live game, store the event ID to fetch live data + if game_data['timestamp'] < 0: # Negative timestamp indicates live game + event_id = event.get('id') + if event_id: + live_event_ids.append((event_id, len(all_games) - 1)) # Store index too + + if not all_games: + return [] + + # Fetch live event data for live games to get real-time scores + for event_id, game_index in live_event_ids: + try: + live_event_data = await self.fetch_live_event_data(event_id, team_info['sport'], team_info['league']) + if live_event_data: + # The event endpoint returns the event directly (not in an array) + # Update the game data with live scores + updated_game = self.parse_game_event_with_timestamp( + live_event_data, team_info['team_id'], team_info['sport'], team_info['league'] + ) + if updated_game: + all_games[game_index] = updated_game + except Exception as e: + self.logger.warning(f"Error fetching live data for event {event_id}: {e}") + + # Sort by timestamp (negative for live games, then by actual timestamp) + # This prioritizes: live games > upcoming games > recent past games + all_games.sort(key=lambda x: x['timestamp']) + + # Get current time for comparison + now = datetime.now(timezone.utc).timestamp() + # 8 days in seconds + eight_days_ago = now - (8 * 24 * 60 * 60) + # 6 weeks in seconds (6 * 7 * 24 * 60 * 60) + six_weeks_from_now = now + (6 * 7 * 24 * 60 * 60) + + # Separate into categories + live_games = [g for g in all_games if g['timestamp'] < 0] # Negative timestamps = live + upcoming_games = [] + past_games = [] + + # Categorize games with positive timestamps + for game in all_games: + if game['timestamp'] < 0: + continue # Already in live_games + + game_event_ts = game.get('event_timestamp') + effective_ts = game_event_ts if game_event_ts is not None else game['timestamp'] + + if game['timestamp'] >= 9999999990 and game_event_ts is None: + # No real timestamp available, treat as past + past_games.append((effective_ts, game)) + elif effective_ts is None: + past_games.append((effective_ts, game)) + elif effective_ts > now: + # Future game - only include if within next 6 weeks + if effective_ts is not None and effective_ts <= six_weeks_from_now: + upcoming_games.append((effective_ts, game)) + else: + # Past game - only include if within last 8 days + if effective_ts is not None and effective_ts >= eight_days_ago: + past_games.append((effective_ts, game)) + + # Sort upcoming games by soonest first, past games by most recent first + upcoming_games.sort(key=lambda x: x[0] if x[0] is not None else float('inf')) + past_games.sort(key=lambda x: x[0] if x[0] is not None else -float('inf'), reverse=True) + + # Build result with new priority: + # 1. Live games (if any) + # 2. Last completed game (if within last 8 days) + # 3. Next scheduled game (if known and within 6 weeks) + result = [] + + # Add live games (if any) + if live_games: + result.extend(live_games) + + # Add last completed game (if within last 8 days) + if past_games: + result.append(past_games[0][1]) # Most recent past game + + # Add next scheduled game (if known and within 6 weeks) + if upcoming_games: + result.append(upcoming_games[0][1]) + + return result + + except Exception as e: + self.logger.error(f"Error fetching team games: {e}") + return [] + + async def fetch_live_event_data(self, event_id: str, sport: str, league: str) -> Optional[Dict]: + """Fetch live event data from the scoreboard endpoint for real-time scores + + The scoreboard endpoint provides more up-to-date scores for live games than the schedule endpoint. + We fetch the scoreboard and find the matching event by ID. + """ + try: + # Use scoreboard endpoint which has live scores + url = f"{self.ESPN_BASE_URL}/{sport}/{league}/scoreboard" + response = requests.get(url, timeout=self.url_timeout) + response.raise_for_status() + data = response.json() + + # Find the event with matching ID in the scoreboard + # Convert event_id to string for comparison (API may return IDs as strings or ints) + event_id_str = str(event_id) + events = data.get('events', []) + for event in events: + event_id_from_api = str(event.get('id', '')) + if event_id_from_api == event_id_str: + return event + + # If not found in scoreboard, return None (event might not be live anymore) + return None + except Exception as e: + self.logger.warning(f"Error fetching live event data for {event_id}: {e}") + return None + + async def fetch_team_game_data(self, team_info: Dict[str, str]) -> Optional[Dict]: + """Fetch structured game data for a team with timestamp for sorting + + Uses the team schedule endpoint which returns both past and upcoming games + in a single API call, eliminating the need for multiple scoreboard requests. + Returns only the most relevant game (for backward compatibility). + """ + games = await self.fetch_team_games(team_info) + return games[0] if games else None + + async def fetch_team_schedule(self, team_info: Dict[str, str]) -> List[Dict]: + """Fetch upcoming scheduled games for a team + + Returns as many upcoming games as available from the schedule endpoint. + Used for 'sports schedule' command. """ try: # Use team schedule endpoint - returns both past and upcoming games @@ -1200,71 +1511,79 @@ class SportsCommand(BaseCommand): if not all_games: return [] - # Sort by timestamp (negative for live games, then by actual timestamp) - # This prioritizes: live games > upcoming games > recent past games - all_games.sort(key=lambda x: x['timestamp']) - # Get current time for comparison now = datetime.now(timezone.utc).timestamp() - # Separate into categories - live_games = [g for g in all_games if g['timestamp'] < 0] # Negative timestamps = live + # Filter to only upcoming games upcoming_games = [] - past_games = [] - - # Categorize games with positive timestamps for game in all_games: + # Skip live games (negative timestamps) if game['timestamp'] < 0: - continue # Already in live_games + continue game_event_ts = game.get('event_timestamp') effective_ts = game_event_ts if game_event_ts is not None else game['timestamp'] - if game['timestamp'] >= 9999999990 and game_event_ts is None: - # No real timestamp available, treat as past - past_games.append((effective_ts, game)) - elif effective_ts is None: - past_games.append((effective_ts, game)) - elif effective_ts > now: - # Future game + # Only include games with valid future timestamps + if effective_ts is not None and effective_ts > now: upcoming_games.append((effective_ts, game)) - else: - # Past game - past_games.append((effective_ts, game)) - # Sort upcoming games by soonest first, past games by most recent first + # Sort by soonest first upcoming_games.sort(key=lambda x: x[0] if x[0] is not None else float('inf')) - past_games.sort(key=lambda x: x[0] if x[0] is not None else -float('inf'), reverse=True) - # Build result: live games + next upcoming + recent past - result = [] - - # Add live games (if any) - if live_games: - result.extend(live_games) - - # Add next upcoming game (if no live games) - if not live_games and upcoming_games: - result.append(upcoming_games[0][1]) - - # Add recent past games (most recent first, up to what fits) - result.extend([g for _, g in past_games]) - - return result + # Return all upcoming games (caller will limit by message length) + return [g for _, g in upcoming_games] except Exception as e: - self.logger.error(f"Error fetching team games: {e}") + self.logger.error(f"Error fetching team schedule: {e}") return [] - async def fetch_team_game_data(self, team_info: Dict[str, str]) -> Optional[Dict]: - """Fetch structured game data for a team with timestamp for sorting + async def fetch_team_schedule_formatted(self, team_info: Dict[str, str]) -> Optional[str]: + """Fetch and format upcoming scheduled games for a team - Uses the team schedule endpoint which returns both past and upcoming games - in a single API call, eliminating the need for multiple scoreboard requests. - Returns only the most relevant game (for backward compatibility). + Returns formatted schedule with as many games as fit in 130 characters. """ - games = await self.fetch_team_games(team_info) - return games[0] if games else None + games = await self.fetch_team_schedule(team_info) + if not games: + return None + + # Format games to fit within message limit (130 characters) + # Use 125 as a buffer to avoid cutting off mid-game + sport_emoji = self.SPORT_EMOJIS.get(team_info['sport'], '🏆') + formatted_games = [] + current_length = 0 + max_length = 125 # Leave buffer to avoid cutoff + + for game in games: + # Ensure game['formatted'] doesn't already have an emoji + game_formatted = game['formatted'].strip() + # Remove emoji if it's at the start (some games might have it) + if game_formatted and game_formatted[0] in self.SPORT_EMOJIS.values(): + game_formatted = game_formatted[1:].strip() + + game_str = f"{sport_emoji} {game_formatted}" + # Check if adding this game would exceed limit + if formatted_games: + # Account for newline separator + test_length = current_length + len("\n") + len(game_str) + else: + test_length = len(game_str) + + if test_length <= max_length: + formatted_games.append(game_str) + current_length = test_length + else: + # Can't fit more games - stop before exceeding limit + break + + if not formatted_games: + # If even the first game doesn't fit, return it anyway (truncated) + game_formatted = games[0]['formatted'].strip() + if game_formatted and game_formatted[0] in self.SPORT_EMOJIS.values(): + game_formatted = game_formatted[1:].strip() + return f"{sport_emoji} {game_formatted[:120]}" + + return "\n".join(formatted_games) def parse_game_event_with_timestamp(self, event: Dict, team_id: str, sport: str, league: str) -> Optional[Dict]: """Parse a game event and return structured data with timestamp for sorting""" @@ -1336,10 +1655,12 @@ class SportsCommand(BaseCommand): pass # Format based on game status - if status_name in ['STATUS_IN_PROGRESS', 'STATUS_FIRST_HALF', 'STATUS_SECOND_HALF']: + if status_name in ['STATUS_IN_PROGRESS', 'STATUS_FIRST_HALF', 'STATUS_SECOND_HALF', 'STATUS_END_PERIOD']: # Game is live - prioritize these (use negative timestamp) + # STATUS_END_PERIOD means a period just ended but game is still ongoing clock = status.get('displayClock', '') period = status.get('period', 0) + is_end_period = (status_name == 'STATUS_END_PERIOD') # Format period based on sport if sport == 'soccer': @@ -1361,9 +1682,13 @@ class SportsCommand(BaseCommand): formatted = f"{away_name} {away_score}-{home_score} @{home_name} ({period_str})" elif sport == 'football': period_str = f"Q{period}" # Quarters + if is_end_period: + period_str = f"End {period_str}" formatted = f"{away_name} {away_score}-{home_score} @{home_name} ({clock} {period_str})" else: - period_str = f"P{period}" # Generic periods + period_str = f"P{period}" # Generic periods (hockey, etc.) + if is_end_period: + period_str = f"End {period_str}" formatted = f"{away_name} {away_score}-{home_score} @{home_name} ({clock} {period_str})" timestamp = -1 # Live games first