diff --git a/modules/clients/__init__.py b/modules/clients/__init__.py new file mode 100644 index 0000000..70f0e6a --- /dev/null +++ b/modules/clients/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +""" +API Clients for MeshCore Bot +Encapsulates external API interactions +""" diff --git a/modules/clients/espn_client.py b/modules/clients/espn_client.py new file mode 100644 index 0000000..5ebdbc8 --- /dev/null +++ b/modules/clients/espn_client.py @@ -0,0 +1,320 @@ +import aiohttp +import logging +import asyncio +from datetime import datetime, timezone +from typing import List, Dict, Optional +from .sports_mappings import is_womens_league, get_team_abbreviation, format_clean_date, format_clean_date_time + +class ESPNClient: + """Client for ESPN API using aiohttp for asynchronous requests""" + + BASE_URL = "http://site.api.espn.com/apis/site/v2/sports" + + def __init__(self, logger=None, timeout: int = 10, session: Optional[aiohttp.ClientSession] = None): + self.logger = logger or logging.getLogger(__name__) + self.timeout = aiohttp.ClientTimeout(total=timeout) + self.session = session + + async def _get_session(self) -> aiohttp.ClientSession: + """Get or create an aiohttp session""" + if self.session is None or self.session.closed: + self.session = aiohttp.ClientSession(timeout=self.timeout) + return self.session + + async def fetch_scoreboard(self, sport: str, league: str) -> List[Dict]: + """Fetch and parse scoreboard data for a league""" + url = f"{self.BASE_URL}/{sport}/{league}/scoreboard" + try: + session = await self._get_session() + async with session.get(url) as response: + response.raise_for_status() + data = await response.json() + events = data.get('events', []) + + parsed_events = [] + for event in events: + parsed = self.parse_league_game_event(event, sport, league) + if parsed: + parsed_events.append(parsed) + return parsed_events + except Exception as e: + self.logger.error(f"ESPN fetch_scoreboard error for {sport}/{league}: {e}") + return [] + + async def fetch_team_schedule(self, sport: str, league: str, team_id: str) -> List[Dict]: + """Fetch and parse schedule data for a team + + For soccer teams, if the team schedule has no upcoming games, we fall back + to searching the league scoreboard for games involving this team. + """ + url = f"{self.BASE_URL}/{sport}/{league}/teams/{team_id}/schedule" + try: + session = await self._get_session() + async with session.get(url) as response: + response.raise_for_status() + data = await response.json() + events = data.get('events', []) + + parsed_events = [] + for event in events: + parsed = self.parse_game_event_with_timestamp(event, team_id, sport, league) + if parsed: + parsed_events.append(parsed) + + # For soccer, if no upcoming games found, check league scoreboard + if sport == 'soccer': + from datetime import datetime, timezone + now = datetime.now(timezone.utc).timestamp() + has_upcoming = any( + g.get('event_timestamp', 0) > now for g in parsed_events + ) + + if not has_upcoming: + # Fall back to league scoreboard to find this team's games + scoreboard_games = await self._find_team_in_scoreboard(sport, league, team_id) + if scoreboard_games: + parsed_events.extend(scoreboard_games) + + return parsed_events + except Exception as e: + self.logger.error(f"ESPN fetch_team_schedule error for {team_id}: {e}") + return [] + + async def _find_team_in_scoreboard(self, sport: str, league: str, team_id: str) -> List[Dict]: + """Find games for a specific team in the league scoreboard""" + url = f"{self.BASE_URL}/{sport}/{league}/scoreboard" + try: + session = await self._get_session() + async with session.get(url) as response: + response.raise_for_status() + data = await response.json() + events = data.get('events', []) + + team_games = [] + for event in events: + # Check if this team is in this event + competitions = event.get('competitions', []) + if not competitions: + continue + + competition = competitions[0] + competitors = competition.get('competitors', []) + + # Check if our team is in this game + team_in_game = False + for competitor in competitors: + if str(competitor.get('team', {}).get('id', '')) == str(team_id): + team_in_game = True + break + + if team_in_game: + parsed = self.parse_game_event_with_timestamp(event, team_id, sport, league) + if parsed: + team_games.append(parsed) + + return team_games + except Exception as e: + self.logger.error(f"Error finding team in scoreboard: {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. + """ + url = f"{self.BASE_URL}/{sport}/{league}/scoreboard" + try: + session = await self._get_session() + async with session.get(url) as response: + response.raise_for_status() + data = await 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.error(f"ESPN fetch_live_event_data error for {event_id}: {e}") + return None + + def extract_score(self, competitor: Dict) -> str: + """Extract score value from competitor data""" + score = competitor.get('score', '0') + if isinstance(score, dict): + if 'displayValue' in score: + return str(score['displayValue']) + elif 'value' in score: + value = score['value'] + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value) + return '0' + if isinstance(score, str): + return score + if isinstance(score, (int, float)): + if isinstance(score, float) and score.is_integer(): + return str(int(score)) + return str(score) + return '0' + + def extract_shootout_score(self, competitor: Dict) -> Optional[int]: + """Extract penalty shootout score from competitor data""" + score = competitor.get('score', {}) + if isinstance(score, dict) and 'shootoutScore' in score: + shootout = score['shootoutScore'] + if isinstance(shootout, (int, float)): + return int(shootout) + return None + + 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""" + try: + competitions = event.get('competitions', []) + if not competitions: + return None + + competition = competitions[0] + competitors = competition.get('competitors', []) + + if len(competitors) != 2: + return None + + # Extract team info + team1 = competitors[0] + team2 = competitors[1] + + # Determine home/away + home_team = team1 if team1.get('homeAway') == 'home' else team2 + away_team = team2 if team1.get('homeAway') == 'home' else team1 + + home_id = home_team.get('team', {}).get('id', '') + away_id = away_team.get('team', {}).get('id', '') + home_abbr = home_team.get('team', {}).get('abbreviation', 'UNK') + away_abbr = away_team.get('team', {}).get('abbreviation', 'UNK') + + home_name = get_team_abbreviation(home_id, home_abbr, sport, league) + away_name = get_team_abbreviation(away_id, away_abbr, sport, league) + + home_score = self.extract_score(home_team) + away_score = self.extract_score(away_team) + + # Get game status + status_obj = competition.get('status', event.get('status', {})) + status_type = status_obj.get('type', {}) + status_name = status_type.get('name', 'UNKNOWN') + + # Get timestamp for sorting + date_str = event.get('date', '') + timestamp = 0 + event_timestamp = None + if date_str: + try: + dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + event_timestamp = dt.timestamp() + timestamp = event_timestamp + except: + pass + + # Format based on game status + formatted = "" + if status_name in ['STATUS_IN_PROGRESS', 'STATUS_FIRST_HALF', 'STATUS_SECOND_HALF', 'STATUS_END_PERIOD']: + # Game is live + clock = status_obj.get('displayClock', '') + period = status_obj.get('period', 0) + is_end_period = (status_name == 'STATUS_END_PERIOD') + + if sport == 'soccer': + # Soccer: @Home Score-Score Away (Clock) + period_str = clock if (clock and clock != '0:00' and clock != "0'") else f"{period}H" + formatted = f"@{home_name} {home_score}-{away_score} {away_name} ({period_str})" + elif sport == 'baseball': + short_detail = status_type.get('shortDetail', '') + period_str = short_detail if ('Top' in short_detail or 'Bottom' in short_detail) else f"{period}I" + 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}" + 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}" + 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 + + elif status_name == 'STATUS_SCHEDULED': + # Scheduled + if event_timestamp: + dt = datetime.fromtimestamp(event_timestamp, tz=timezone.utc).astimezone() + time_str = format_clean_date_time(dt) + if sport == 'soccer': + formatted = f"@{home_name} vs. {away_name} ({time_str})" + else: + formatted = f"{away_abbr} @ {home_abbr} ({time_str})" + else: + formatted = f"{away_abbr} @ {home_abbr} (TBD)" if sport != 'soccer' else f"@{home_name} vs. {away_name} (TBD)" + timestamp = 9999999999 + + elif status_name == 'STATUS_HALFTIME': + if sport == 'soccer': + formatted = f"@{home_name} {home_score}-{away_score} {away_name} (HT)" + else: + formatted = f"{away_abbr} {away_score}-{home_score} @{home_abbr} (HT)" + timestamp = -2 + + elif status_name in ['STATUS_FINAL', 'STATUS_FULL_TIME', 'STATUS_FINAL_PEN', 'STATUS_POSTPONED']: + date_suffix = "" + if event_timestamp: + dt = datetime.fromtimestamp(event_timestamp, tz=timezone.utc).astimezone() + if dt.date() != datetime.now().date(): + date_suffix = f", {format_clean_date(dt)}" + + if status_name == 'STATUS_FINAL_PEN': + home_shootout = self.extract_shootout_score(home_team) + away_shootout = self.extract_shootout_score(away_team) + pen_str = f"FT-PEN {home_shootout}-{away_shootout}" if home_shootout is not None else "FT-PEN" + formatted = f"@{home_name} {home_score}-{away_score} {away_name} ({pen_str}{date_suffix})" + elif status_name == 'STATUS_FULL_TIME': + formatted = f"@{home_name} {home_score}-{away_score} {away_name} (FT{date_suffix})" + elif status_name == 'STATUS_POSTPONED': + formatted = f"{away_abbr} @ {home_abbr} (Postponed{date_suffix})" + else: + formatted = f"{away_abbr} {away_score}-{home_score} @{home_abbr} (F{date_suffix})" + + timestamp = 9999999998 + else: + prefix = "@" if sport == 'soccer' else "" + suffix = " vs. " if sport == 'soccer' else " @ " + if sport == 'soccer': + formatted = f"@{home_name} {home_score}-{away_score} {away_name} ({status_name})" + else: + formatted = f"{away_name} {away_score}-{home_score} @{home_name} ({status_name})" + timestamp = 9999999997 + + return { + 'id': event.get('id'), + 'timestamp': timestamp, + 'event_timestamp': event_timestamp, + 'formatted': formatted, + 'sport': sport, + 'league': league, + 'status': status_name + } + except Exception as e: + self.logger.error(f"Error parsing ESPN event {event.get('id')}: {e}") + return None + + def parse_league_game_event(self, event: Dict, sport: str, league: str) -> Optional[Dict]: + """Parse a league game event (scoreboard)""" + return self.parse_game_event_with_timestamp(event, "", sport, league) diff --git a/modules/clients/sports_mappings.py b/modules/clients/sports_mappings.py new file mode 100644 index 0000000..dc3de4a --- /dev/null +++ b/modules/clients/sports_mappings.py @@ -0,0 +1,802 @@ +#!/usr/bin/env python3 +""" +Sports Team and League Mappings +Contains team IDs and custom abbreviations for various sports APIs +""" + +# Sport emojis for easy identification +SPORT_EMOJIS = { + 'football': '🏈', + 'baseball': '⚾', + 'basketball': '🏀', + 'hockey': '🏒', + 'soccer': '⚽' +} + +# Custom team abbreviations to distinguish between leagues +# Only use -W suffixes for women's leagues +WOMENS_TEAM_ABBREVIATIONS = { + # NWSL teams - use custom abbreviations to distinguish from MLS + '21422': 'LA-W', # Angel City FC (Women's) + '22187': 'BAY-W', # Bay FC (Women's) + '15360': 'CHI-W', # Chicago Stars FC (Women's) + '15364': 'GFC-W', # Gotham FC (Women's) + '17346': 'HOU-W', # Houston Dash (Women's) + '20907': 'KC-W', # Kansas City Current (Women's) + '15366': 'NC-W', # North Carolina Courage (Women's) + '18206': 'ORL-W', # Orlando Pride (Women's) + '15362': 'POR-W', # Portland Thorns FC (Women's) + '20905': 'LOU-W', # Racing Louisville FC (Women's) + '21423': 'SD-W', # San Diego Wave FC (Women's) + '15363': 'SEA-W', # Seattle Reign FC (Women's) + '19141': 'UTA-W', # Utah Royals (Women's) + '15365': 'WAS-W', # Washington Spirit (Women's) + # WNBA teams - use custom abbreviations to distinguish from NBA + '14': 'SEA-W', # Seattle Storm (Women's) + '9': 'NY-W', # New York Liberty (Women's) + '6': 'LA-W', # Los Angeles Sparks (Women's) + '19': 'CHI-W', # Chicago Sky (Women's) + '20': 'ATL-W', # Atlanta Dream (Women's) + '18': 'CON-W', # Connecticut Sun (Women's) + '3': 'DAL-W', # Dallas Wings (Women's) + '129689': 'GS-W', # Golden State Valkyries (Women's) + '5': 'IND-W', # Indiana Fever (Women's) + '17': 'LV-W', # Las Vegas Aces (Women's) + '8': 'MIN-W', # Minnesota Lynx (Women's) + '11': 'PHX-W', # Phoenix Mercury (Women's) + '16': 'WSH-W', # Washington Mystics (Women's) +} + +# Team mappings for common searches +TEAM_MAPPINGS = { + # NFL Teams + 'seahawks': {'sport': 'football', 'league': 'nfl', 'team_id': '26'}, + 'hawks': {'sport': 'football', 'league': 'nfl', 'team_id': '26'}, + '49ers': {'sport': 'football', 'league': 'nfl', 'team_id': '25'}, + 'niners': {'sport': 'football', 'league': 'nfl', 'team_id': '25'}, + 'sf': {'sport': 'football', 'league': 'nfl', 'team_id': '25'}, + 'bears': {'sport': 'football', 'league': 'nfl', 'team_id': '3'}, + 'chicago': {'sport': 'football', 'league': 'nfl', 'team_id': '3'}, + 'chi': {'sport': 'football', 'league': 'nfl', 'team_id': '3'}, + 'bengals': {'sport': 'football', 'league': 'nfl', 'team_id': '4'}, + 'cincinnati': {'sport': 'football', 'league': 'nfl', 'team_id': '4'}, + 'cin': {'sport': 'football', 'league': 'nfl', 'team_id': '4'}, + 'bills': {'sport': 'football', 'league': 'nfl', 'team_id': '2'}, + 'buffalo': {'sport': 'football', 'league': 'nfl', 'team_id': '2'}, + 'buf': {'sport': 'football', 'league': 'nfl', 'team_id': '2'}, + 'broncos': {'sport': 'football', 'league': 'nfl', 'team_id': '7'}, + 'denver': {'sport': 'football', 'league': 'nfl', 'team_id': '7'}, + 'den': {'sport': 'football', 'league': 'nfl', 'team_id': '7'}, + 'browns': {'sport': 'football', 'league': 'nfl', 'team_id': '5'}, + 'cleveland': {'sport': 'football', 'league': 'nfl', 'team_id': '5'}, + 'cle': {'sport': 'football', 'league': 'nfl', 'team_id': '5'}, + 'buccaneers': {'sport': 'football', 'league': 'nfl', 'team_id': '27'}, + 'bucs': {'sport': 'football', 'league': 'nfl', 'team_id': '27'}, + 'tampa bay': {'sport': 'football', 'league': 'nfl', 'team_id': '27'}, + 'tb': {'sport': 'football', 'league': 'nfl', 'team_id': '27'}, + 'arizona cardinals': {'sport': 'football', 'league': 'nfl', 'team_id': '22'}, + 'az cardinals': {'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'}, + 'colts': {'sport': 'football', 'league': 'nfl', 'team_id': '11'}, + 'indianapolis': {'sport': 'football', 'league': 'nfl', 'team_id': '11'}, + 'ind': {'sport': 'football', 'league': 'nfl', 'team_id': '11'}, + 'commanders': {'sport': 'football', 'league': 'nfl', 'team_id': '28'}, + 'washington': {'sport': 'football', 'league': 'nfl', 'team_id': '28'}, + 'wsh': {'sport': 'football', 'league': 'nfl', 'team_id': '28'}, + 'cowboys': {'sport': 'football', 'league': 'nfl', 'team_id': '6'}, + 'dallas': {'sport': 'football', 'league': 'nfl', 'team_id': '6'}, + 'dal': {'sport': 'football', 'league': 'nfl', 'team_id': '6'}, + 'dolphins': {'sport': 'football', 'league': 'nfl', 'team_id': '15'}, + 'miami': {'sport': 'football', 'league': 'nfl', 'team_id': '15'}, + 'mia': {'sport': 'football', 'league': 'nfl', 'team_id': '15'}, + 'eagles': {'sport': 'football', 'league': 'nfl', 'team_id': '21'}, + 'philadelphia': {'sport': 'football', 'league': 'nfl', 'team_id': '21'}, + 'phi': {'sport': 'football', 'league': 'nfl', 'team_id': '21'}, + 'falcons': {'sport': 'football', 'league': 'nfl', 'team_id': '1'}, + 'atlanta': {'sport': 'football', 'league': 'nfl', 'team_id': '1'}, + 'atl': {'sport': 'football', 'league': 'nfl', 'team_id': '1'}, + 'giants': {'sport': 'football', 'league': 'nfl', 'team_id': '19'}, + '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'}, + 'detroit': {'sport': 'football', 'league': 'nfl', 'team_id': '8'}, + 'det': {'sport': 'football', 'league': 'nfl', 'team_id': '8'}, + 'packers': {'sport': 'football', 'league': 'nfl', 'team_id': '9'}, + 'green bay': {'sport': 'football', 'league': 'nfl', 'team_id': '9'}, + 'gb': {'sport': 'football', 'league': 'nfl', 'team_id': '9'}, + 'carolina panthers': {'sport': 'football', 'league': 'nfl', 'team_id': '29'}, + 'patriots': {'sport': 'football', 'league': 'nfl', 'team_id': '17'}, + 'new england': {'sport': 'football', 'league': 'nfl', 'team_id': '17'}, + 'ne': {'sport': 'football', 'league': 'nfl', 'team_id': '17'}, + 'raiders': {'sport': 'football', 'league': 'nfl', 'team_id': '13'}, + 'las vegas': {'sport': 'football', 'league': 'nfl', 'team_id': '13'}, + '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'}, + 'saints': {'sport': 'football', 'league': 'nfl', 'team_id': '18'}, + 'new orleans': {'sport': 'football', 'league': 'nfl', 'team_id': '18'}, + 'no': {'sport': 'football', 'league': 'nfl', 'team_id': '18'}, + 'steelers': {'sport': 'football', 'league': 'nfl', 'team_id': '23'}, + 'pittsburgh': {'sport': 'football', 'league': 'nfl', 'team_id': '23'}, + 'pit': {'sport': 'football', 'league': 'nfl', 'team_id': '23'}, + 'texans': {'sport': 'football', 'league': 'nfl', 'team_id': '34'}, + 'houston': {'sport': 'football', 'league': 'nfl', 'team_id': '34'}, + 'hou': {'sport': 'football', 'league': 'nfl', 'team_id': '34'}, + 'titans': {'sport': 'football', 'league': 'nfl', 'team_id': '10'}, + 'tennessee': {'sport': 'football', 'league': 'nfl', 'team_id': '10'}, + 'ten': {'sport': 'football', 'league': 'nfl', 'team_id': '10'}, + 'vikings': {'sport': 'football', 'league': 'nfl', 'team_id': '16'}, + 'minnesota': {'sport': 'football', 'league': 'nfl', 'team_id': '16'}, + 'min': {'sport': 'football', 'league': 'nfl', 'team_id': '16'}, + + # CFL Teams (Canadian Football League) + 'bc lions': {'sport': 'football', 'league': 'cfl', 'team_id': '79'}, + 'bcl': {'sport': 'football', 'league': 'cfl', 'team_id': '79'}, + 'calgary stampeders': {'sport': 'football', 'league': 'cfl', 'team_id': '80'}, + 'stampeders': {'sport': 'football', 'league': 'cfl', 'team_id': '80'}, + 'csp': {'sport': 'football', 'league': 'cfl', 'team_id': '80'}, + 'edmonton elks': {'sport': 'football', 'league': 'cfl', 'team_id': '81'}, + 'elks': {'sport': 'football', 'league': 'cfl', 'team_id': '81'}, + 'ees': {'sport': 'football', 'league': 'cfl', 'team_id': '81'}, + 'hamilton tiger-cats': {'sport': 'football', 'league': 'cfl', 'team_id': '82'}, + 'tiger-cats': {'sport': 'football', 'league': 'cfl', 'team_id': '82'}, + 'tigercats': {'sport': 'football', 'league': 'cfl', 'team_id': '82'}, + 'htc': {'sport': 'football', 'league': 'cfl', 'team_id': '82'}, + 'montreal alouettes': {'sport': 'football', 'league': 'cfl', 'team_id': '83'}, + 'alouettes': {'sport': 'football', 'league': 'cfl', 'team_id': '83'}, + 'mta': {'sport': 'football', 'league': 'cfl', 'team_id': '83'}, + 'ottawa redblacks': {'sport': 'football', 'league': 'cfl', 'team_id': '87'}, + 'redblacks': {'sport': 'football', 'league': 'cfl', 'team_id': '87'}, + 'red blacks': {'sport': 'football', 'league': 'cfl', 'team_id': '87'}, + 'orb': {'sport': 'football', 'league': 'cfl', 'team_id': '87'}, + 'saskatchewan roughriders': {'sport': 'football', 'league': 'cfl', 'team_id': '84'}, + 'roughriders': {'sport': 'football', 'league': 'cfl', 'team_id': '84'}, + 'riders': {'sport': 'football', 'league': 'cfl', 'team_id': '84'}, + 'srr': {'sport': 'football', 'league': 'cfl', 'team_id': '84'}, + 'toronto argonauts': {'sport': 'football', 'league': 'cfl', 'team_id': '85'}, + 'argonauts': {'sport': 'football', 'league': 'cfl', 'team_id': '85'}, + 'argos': {'sport': 'football', 'league': 'cfl', 'team_id': '85'}, + 'tat': {'sport': 'football', 'league': 'cfl', 'team_id': '85'}, + 'winnipeg blue bombers': {'sport': 'football', 'league': 'cfl', 'team_id': '86'}, + 'blue bombers': {'sport': 'football', 'league': 'cfl', 'team_id': '86'}, + 'bombers': {'sport': 'football', 'league': 'cfl', 'team_id': '86'}, + 'wbb': {'sport': 'football', 'league': 'cfl', 'team_id': '86'}, + + # MLB Teams + 'mariners': {'sport': 'baseball', 'league': 'mlb', 'team_id': '12'}, + 'seattle': {'sport': 'baseball', 'league': 'mlb', 'team_id': '12'}, + 'sea': {'sport': 'baseball', 'league': 'mlb', 'team_id': '12'}, + 'angels': {'sport': 'baseball', 'league': 'mlb', 'team_id': '3'}, + 'laa': {'sport': 'baseball', 'league': 'mlb', 'team_id': '3'}, + 'astros': {'sport': 'baseball', 'league': 'mlb', 'team_id': '18'}, + 'houston': {'sport': 'baseball', 'league': 'mlb', 'team_id': '18'}, + 'hou': {'sport': 'baseball', 'league': 'mlb', 'team_id': '18'}, + 'athletics': {'sport': 'baseball', 'league': 'mlb', 'team_id': '11'}, + 'a\'s': {'sport': 'baseball', 'league': 'mlb', 'team_id': '11'}, + 'oakland': {'sport': 'baseball', 'league': 'mlb', 'team_id': '11'}, + 'oak': {'sport': 'baseball', 'league': 'mlb', 'team_id': '11'}, + 'blue jays': {'sport': 'baseball', 'league': 'mlb', 'team_id': '14'}, + 'toronto': {'sport': 'baseball', 'league': 'mlb', 'team_id': '14'}, + 'tor': {'sport': 'baseball', 'league': 'mlb', 'team_id': '14'}, + 'braves': {'sport': 'baseball', 'league': 'mlb', 'team_id': '15'}, + 'atlanta': {'sport': 'baseball', 'league': 'mlb', 'team_id': '15'}, + 'atl': {'sport': 'baseball', 'league': 'mlb', 'team_id': '15'}, + 'brewers': {'sport': 'baseball', 'league': 'mlb', 'team_id': '8'}, + 'milwaukee': {'sport': 'baseball', 'league': 'mlb', 'team_id': '8'}, + 'mil': {'sport': 'baseball', 'league': 'mlb', 'team_id': '8'}, + 'cardinals': {'sport': 'baseball', 'league': 'mlb', 'team_id': '24'}, + 'st louis': {'sport': 'baseball', 'league': 'mlb', 'team_id': '24'}, + 'stl': {'sport': 'baseball', 'league': 'mlb', 'team_id': '24'}, + 'cubs': {'sport': 'baseball', 'league': 'mlb', 'team_id': '16'}, + 'chicago': {'sport': 'baseball', 'league': 'mlb', 'team_id': '16'}, + 'chc': {'sport': 'baseball', 'league': 'mlb', 'team_id': '16'}, + 'diamondbacks': {'sport': 'baseball', 'league': 'mlb', 'team_id': '29'}, + 'arizona': {'sport': 'baseball', 'league': 'mlb', 'team_id': '29'}, + 'ari': {'sport': 'baseball', 'league': 'mlb', 'team_id': '29'}, + 'dodgers': {'sport': 'baseball', 'league': 'mlb', 'team_id': '19'}, + 'lad': {'sport': 'baseball', 'league': 'mlb', 'team_id': '19'}, + 'giants': {'sport': 'baseball', 'league': 'mlb', 'team_id': '26'}, + 'san francisco': {'sport': 'baseball', 'league': 'mlb', 'team_id': '26'}, + 'sf': {'sport': 'baseball', 'league': 'mlb', 'team_id': '26'}, + 'guardians': {'sport': 'baseball', 'league': 'mlb', 'team_id': '5'}, + 'cleveland': {'sport': 'baseball', 'league': 'mlb', 'team_id': '5'}, + 'cle': {'sport': 'baseball', 'league': 'mlb', 'team_id': '5'}, + 'marlins': {'sport': 'baseball', 'league': 'mlb', 'team_id': '28'}, + 'miami': {'sport': 'baseball', 'league': 'mlb', 'team_id': '28'}, + 'mia': {'sport': 'baseball', 'league': 'mlb', 'team_id': '28'}, + 'mets': {'sport': 'baseball', 'league': 'mlb', 'team_id': '21'}, + 'nym': {'sport': 'baseball', 'league': 'mlb', 'team_id': '21'}, + 'nationals': {'sport': 'baseball', 'league': 'mlb', 'team_id': '20'}, + 'washington': {'sport': 'baseball', 'league': 'mlb', 'team_id': '20'}, + 'was': {'sport': 'baseball', 'league': 'mlb', 'team_id': '20'}, + 'orioles': {'sport': 'baseball', 'league': 'mlb', 'team_id': '1'}, + 'baltimore': {'sport': 'baseball', 'league': 'mlb', 'team_id': '1'}, + 'bal': {'sport': 'baseball', 'league': 'mlb', 'team_id': '1'}, + 'padres': {'sport': 'baseball', 'league': 'mlb', 'team_id': '25'}, + 'san diego': {'sport': 'baseball', 'league': 'mlb', 'team_id': '25'}, + 'sd': {'sport': 'baseball', 'league': 'mlb', 'team_id': '25'}, + 'phillies': {'sport': 'baseball', 'league': 'mlb', 'team_id': '22'}, + 'philadelphia': {'sport': 'baseball', 'league': 'mlb', 'team_id': '22'}, + 'phi': {'sport': 'baseball', 'league': 'mlb', 'team_id': '22'}, + 'pirates': {'sport': 'baseball', 'league': 'mlb', 'team_id': '23'}, + 'pittsburgh': {'sport': 'baseball', 'league': 'mlb', 'team_id': '23'}, + 'pit': {'sport': 'baseball', 'league': 'mlb', 'team_id': '23'}, + 'rangers': {'sport': 'baseball', 'league': 'mlb', 'team_id': '13'}, + 'texas': {'sport': 'baseball', 'league': 'mlb', 'team_id': '13'}, + 'tex': {'sport': 'baseball', 'league': 'mlb', 'team_id': '13'}, + 'rays': {'sport': 'baseball', 'league': 'mlb', 'team_id': '30'}, + 'tampa bay': {'sport': 'baseball', 'league': 'mlb', 'team_id': '30'}, + 'tb': {'sport': 'baseball', 'league': 'mlb', 'team_id': '30'}, + 'red sox': {'sport': 'baseball', 'league': 'mlb', 'team_id': '2'}, + 'boston': {'sport': 'baseball', 'league': 'mlb', 'team_id': '2'}, + 'bos': {'sport': 'baseball', 'league': 'mlb', 'team_id': '2'}, + 'reds': {'sport': 'baseball', 'league': 'mlb', 'team_id': '17'}, + 'cincinnati': {'sport': 'baseball', 'league': 'mlb', 'team_id': '17'}, + 'cin': {'sport': 'baseball', 'league': 'mlb', 'team_id': '17'}, + 'rockies': {'sport': 'baseball', 'league': 'mlb', 'team_id': '27'}, + 'colorado': {'sport': 'baseball', 'league': 'mlb', 'team_id': '27'}, + 'col': {'sport': 'baseball', 'league': 'mlb', 'team_id': '27'}, + 'royals': {'sport': 'baseball', 'league': 'mlb', 'team_id': '7'}, + 'kansas city': {'sport': 'baseball', 'league': 'mlb', 'team_id': '7'}, + 'kc': {'sport': 'baseball', 'league': 'mlb', 'team_id': '7'}, + 'tigers': {'sport': 'baseball', 'league': 'mlb', 'team_id': '6'}, + 'detroit': {'sport': 'baseball', 'league': 'mlb', 'team_id': '6'}, + 'det': {'sport': 'baseball', 'league': 'mlb', 'team_id': '6'}, + 'twins': {'sport': 'baseball', 'league': 'mlb', 'team_id': '9'}, + 'minnesota': {'sport': 'baseball', 'league': 'mlb', 'team_id': '9'}, + 'min': {'sport': 'baseball', 'league': 'mlb', 'team_id': '9'}, + 'white sox': {'sport': 'baseball', 'league': 'mlb', 'team_id': '4'}, + 'chw': {'sport': 'baseball', 'league': 'mlb', 'team_id': '4'}, + 'yankees': {'sport': 'baseball', 'league': 'mlb', 'team_id': '10'}, + 'new york': {'sport': 'baseball', 'league': 'mlb', 'team_id': '10'}, + 'nyy': {'sport': 'baseball', 'league': 'mlb', 'team_id': '10'}, + + # NBA Teams + 'hawks': {'sport': 'basketball', 'league': 'nba', 'team_id': '1'}, + 'atlanta hawks': {'sport': 'basketball', 'league': 'nba', 'team_id': '1'}, + 'celtics': {'sport': 'basketball', 'league': 'nba', 'team_id': '2'}, + 'boston celtics': {'sport': 'basketball', 'league': 'nba', 'team_id': '2'}, + 'nets': {'sport': 'basketball', 'league': 'nba', 'team_id': '17'}, + 'brooklyn nets': {'sport': 'basketball', 'league': 'nba', 'team_id': '17'}, + 'hornets': {'sport': 'basketball', 'league': 'nba', 'team_id': '30'}, + 'charlotte hornets': {'sport': 'basketball', 'league': 'nba', 'team_id': '30'}, + 'bulls': {'sport': 'basketball', 'league': 'nba', 'team_id': '4'}, + 'chicago bulls': {'sport': 'basketball', 'league': 'nba', 'team_id': '4'}, + 'cavaliers': {'sport': 'basketball', 'league': 'nba', 'team_id': '5'}, + 'cavs': {'sport': 'basketball', 'league': 'nba', 'team_id': '5'}, + 'cleveland cavaliers': {'sport': 'basketball', 'league': 'nba', 'team_id': '5'}, + 'mavericks': {'sport': 'basketball', 'league': 'nba', 'team_id': '6'}, + 'mavs': {'sport': 'basketball', 'league': 'nba', 'team_id': '6'}, + 'dallas mavericks': {'sport': 'basketball', 'league': 'nba', 'team_id': '6'}, + 'nuggets': {'sport': 'basketball', 'league': 'nba', 'team_id': '7'}, + 'denver nuggets': {'sport': 'basketball', 'league': 'nba', 'team_id': '7'}, + 'pistons': {'sport': 'basketball', 'league': 'nba', 'team_id': '8'}, + 'detroit pistons': {'sport': 'basketball', 'league': 'nba', 'team_id': '8'}, + 'warriors': {'sport': 'basketball', 'league': 'nba', 'team_id': '9'}, + 'golden state warriors': {'sport': 'basketball', 'league': 'nba', 'team_id': '9'}, + 'rockets': {'sport': 'basketball', 'league': 'nba', 'team_id': '10'}, + 'houston rockets': {'sport': 'basketball', 'league': 'nba', 'team_id': '10'}, + 'pacers': {'sport': 'basketball', 'league': 'nba', 'team_id': '11'}, + 'indiana pacers': {'sport': 'basketball', 'league': 'nba', 'team_id': '11'}, + 'clippers': {'sport': 'basketball', 'league': 'nba', 'team_id': '12'}, + 'la clippers': {'sport': 'basketball', 'league': 'nba', 'team_id': '12'}, + 'lakers': {'sport': 'basketball', 'league': 'nba', 'team_id': '13'}, + 'la lakers': {'sport': 'basketball', 'league': 'nba', 'team_id': '13'}, + 'heat': {'sport': 'basketball', 'league': 'nba', 'team_id': '14'}, + 'miami heat': {'sport': 'basketball', 'league': 'nba', 'team_id': '14'}, + 'bucks': {'sport': 'basketball', 'league': 'nba', 'team_id': '15'}, + 'milwaukee bucks': {'sport': 'basketball', 'league': 'nba', 'team_id': '15'}, + 'timberwolves': {'sport': 'basketball', 'league': 'nba', 'team_id': '16'}, + 'twolves': {'sport': 'basketball', 'league': 'nba', 'team_id': '16'}, + 'minnesota timberwolves': {'sport': 'basketball', 'league': 'nba', 'team_id': '16'}, + 'pelicans': {'sport': 'basketball', 'league': 'nba', 'team_id': '3'}, + 'new orleans pelicans': {'sport': 'basketball', 'league': 'nba', 'team_id': '3'}, + 'knicks': {'sport': 'basketball', 'league': 'nba', 'team_id': '18'}, + 'new york knicks': {'sport': 'basketball', 'league': 'nba', 'team_id': '18'}, + 'magic': {'sport': 'basketball', 'league': 'nba', 'team_id': '19'}, + 'orlando magic': {'sport': 'basketball', 'league': 'nba', 'team_id': '19'}, + '76ers': {'sport': 'basketball', 'league': 'nba', 'team_id': '20'}, + 'sixers': {'sport': 'basketball', 'league': 'nba', 'team_id': '20'}, + 'philadelphia 76ers': {'sport': 'basketball', 'league': 'nba', 'team_id': '20'}, + 'suns': {'sport': 'basketball', 'league': 'nba', 'team_id': '21'}, + 'phoenix suns': {'sport': 'basketball', 'league': 'nba', 'team_id': '21'}, + 'trail blazers': {'sport': 'basketball', 'league': 'nba', 'team_id': '22'}, + 'trailblazers': {'sport': 'basketball', 'league': 'nba', 'team_id': '22'}, + 'blazers': {'sport': 'basketball', 'league': 'nba', 'team_id': '22'}, + 'portland trail blazers': {'sport': 'basketball', 'league': 'nba', 'team_id': '22'}, + 'kings': {'sport': 'basketball', 'league': 'nba', 'team_id': '23'}, + 'sacramento kings': {'sport': 'basketball', 'league': 'nba', 'team_id': '23'}, + 'spurs': {'sport': 'basketball', 'league': 'nba', 'team_id': '24'}, + 'san antonio spurs': {'sport': 'basketball', 'league': 'nba', 'team_id': '24'}, + 'thunder': {'sport': 'basketball', 'league': 'nba', 'team_id': '25'}, + 'okc thunder': {'sport': 'basketball', 'league': 'nba', 'team_id': '25'}, + 'oklahoma city thunder': {'sport': 'basketball', 'league': 'nba', 'team_id': '25'}, + 'jazz': {'sport': 'basketball', 'league': 'nba', 'team_id': '26'}, + 'utah jazz': {'sport': 'basketball', 'league': 'nba', 'team_id': '26'}, + 'wizards': {'sport': 'basketball', 'league': 'nba', 'team_id': '27'}, + 'washington wizards': {'sport': 'basketball', 'league': 'nba', 'team_id': '27'}, + 'raptors': {'sport': 'basketball', 'league': 'nba', 'team_id': '28'}, + 'toronto raptors': {'sport': 'basketball', 'league': 'nba', 'team_id': '28'}, + 'grizzlies': {'sport': 'basketball', 'league': 'nba', 'team_id': '29'}, + 'memphis grizzlies': {'sport': 'basketball', 'league': 'nba', 'team_id': '29'}, + + # WNBA Teams + 'storm': {'sport': 'basketball', 'league': 'wnba', 'team_id': '14'}, + 'seattle storm': {'sport': 'basketball', 'league': 'wnba', 'team_id': '14'}, + 'liberty': {'sport': 'basketball', 'league': 'wnba', 'team_id': '9'}, + 'new york liberty': {'sport': 'basketball', 'league': 'wnba', 'team_id': '9'}, + 'sparks': {'sport': 'basketball', 'league': 'wnba', 'team_id': '6'}, + 'los angeles sparks': {'sport': 'basketball', 'league': 'wnba', 'team_id': '6'}, + 'sky': {'sport': 'basketball', 'league': 'wnba', 'team_id': '19'}, + 'chicago sky': {'sport': 'basketball', 'league': 'wnba', 'team_id': '19'}, + 'dream': {'sport': 'basketball', 'league': 'wnba', 'team_id': '20'}, + 'atlanta dream': {'sport': 'basketball', 'league': 'wnba', 'team_id': '20'}, + 'sun': {'sport': 'basketball', 'league': 'wnba', 'team_id': '18'}, + 'connecticut sun': {'sport': 'basketball', 'league': 'wnba', 'team_id': '18'}, + 'wings': {'sport': 'basketball', 'league': 'wnba', 'team_id': '3'}, + 'dallas wings': {'sport': 'basketball', 'league': 'wnba', 'team_id': '3'}, + 'valkyries': {'sport': 'basketball', 'league': 'wnba', 'team_id': '129689'}, + 'golden state valkyries': {'sport': 'basketball', 'league': 'wnba', 'team_id': '129689'}, + 'fever': {'sport': 'basketball', 'league': 'wnba', 'team_id': '5'}, + 'indiana fever': {'sport': 'basketball', 'league': 'wnba', 'team_id': '5'}, + 'aces': {'sport': 'basketball', 'league': 'wnba', 'team_id': '17'}, + 'las vegas aces': {'sport': 'basketball', 'league': 'wnba', 'team_id': '17'}, + 'lynx': {'sport': 'basketball', 'league': 'wnba', 'team_id': '8'}, + 'minnesota lynx': {'sport': 'basketball', 'league': 'wnba', 'team_id': '8'}, + 'mercury': {'sport': 'basketball', 'league': 'wnba', 'team_id': '11'}, + 'phoenix mercury': {'sport': 'basketball', 'league': 'wnba', 'team_id': '11'}, + 'mystics': {'sport': 'basketball', 'league': 'wnba', 'team_id': '16'}, + 'washington mystics': {'sport': 'basketball', 'league': 'wnba', 'team_id': '16'}, + + # 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'}, + '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'}, + + # WHL Teams (Western Hockey League) - using TheSportsDB API + 'thunderbirds': {'sport': 'hockey', 'league': 'whl', 'team_id': '144380', 'api_source': 'thesportsdb'}, + 'seattle thunderbirds': {'sport': 'hockey', 'league': 'whl', 'team_id': '144380', 'api_source': 'thesportsdb'}, + 't-birds': {'sport': 'hockey', 'league': 'whl', 'team_id': '144380', 'api_source': 'thesportsdb'}, + 'winterhawks': {'sport': 'hockey', 'league': 'whl', 'team_id': '144379', 'api_source': 'thesportsdb'}, + 'portland winterhawks': {'sport': 'hockey', 'league': 'whl', 'team_id': '144379', 'api_source': 'thesportsdb'}, + 'silvertips': {'sport': 'hockey', 'league': 'whl', 'team_id': '144378', 'api_source': 'thesportsdb'}, + 'everett silvertips': {'sport': 'hockey', 'league': 'whl', 'team_id': '144378', 'api_source': 'thesportsdb'}, + 'everett': {'sport': 'hockey', 'league': 'whl', 'team_id': '144378', 'api_source': 'thesportsdb'}, + 'spokane chiefs': {'sport': 'hockey', 'league': 'whl', 'team_id': '144381', 'api_source': 'thesportsdb'}, + 'spokane': {'sport': 'hockey', 'league': 'whl', 'team_id': '144381', 'api_source': 'thesportsdb'}, + 'vancouver giants': {'sport': 'hockey', 'league': 'whl', 'team_id': '144376', 'api_source': 'thesportsdb'}, + 'blazers': {'sport': 'hockey', 'league': 'whl', 'team_id': '144373', 'api_source': 'thesportsdb'}, + 'kamloops blazers': {'sport': 'hockey', 'league': 'whl', 'team_id': '144373', 'api_source': 'thesportsdb'}, + 'kamloops': {'sport': 'hockey', 'league': 'whl', 'team_id': '144373', 'api_source': 'thesportsdb'}, + 'cougars': {'sport': 'hockey', 'league': 'whl', 'team_id': '144375', 'api_source': 'thesportsdb'}, + 'prince george cougars': {'sport': 'hockey', 'league': 'whl', 'team_id': '144375', 'api_source': 'thesportsdb'}, + 'prince george': {'sport': 'hockey', 'league': 'whl', 'team_id': '144375', 'api_source': 'thesportsdb'}, + 'rockets': {'sport': 'hockey', 'league': 'whl', 'team_id': '144374', 'api_source': 'thesportsdb'}, + 'kelowna rockets': {'sport': 'hockey', 'league': 'whl', 'team_id': '144374', 'api_source': 'thesportsdb'}, + 'kelowna': {'sport': 'hockey', 'league': 'whl', 'team_id': '144374', 'api_source': 'thesportsdb'}, + 'tri-city americans': {'sport': 'hockey', 'league': 'whl', 'team_id': '144382', 'api_source': 'thesportsdb'}, + 'americans': {'sport': 'hockey', 'league': 'whl', 'team_id': '144382', 'api_source': 'thesportsdb'}, + 'tri city': {'sport': 'hockey', 'league': 'whl', 'team_id': '144382', 'api_source': 'thesportsdb'}, + 'tricity': {'sport': 'hockey', 'league': 'whl', 'team_id': '144382', 'api_source': 'thesportsdb'}, + 'wenatchee wild': {'sport': 'hockey', 'league': 'whl', 'team_id': '144372', 'api_source': 'thesportsdb'}, + 'wenatchee': {'sport': 'hockey', 'league': 'whl', 'team_id': '144372', 'api_source': 'thesportsdb'}, + 'victoria royals': {'sport': 'hockey', 'league': 'whl', 'team_id': '144377', 'api_source': 'thesportsdb'}, + 'victoria': {'sport': 'hockey', 'league': 'whl', 'team_id': '144377', 'api_source': 'thesportsdb'}, + 'edmonton oil kings': {'sport': 'hockey', 'league': 'whl', 'team_id': '144362', 'api_source': 'thesportsdb'}, + 'oil kings': {'sport': 'hockey', 'league': 'whl', 'team_id': '144362', 'api_source': 'thesportsdb'}, + 'calgary hitmen': {'sport': 'hockey', 'league': 'whl', 'team_id': '144361', 'api_source': 'thesportsdb'}, + 'hitmen': {'sport': 'hockey', 'league': 'whl', 'team_id': '144361', 'api_source': 'thesportsdb'}, + 'red deer rebels': {'sport': 'hockey', 'league': 'whl', 'team_id': '144365', 'api_source': 'thesportsdb'}, + 'red deer': {'sport': 'hockey', 'league': 'whl', 'team_id': '144365', 'api_source': 'thesportsdb'}, + 'medicine hat tigers': {'sport': 'hockey', 'league': 'whl', 'team_id': '144364', 'api_source': 'thesportsdb'}, + 'medicine hat': {'sport': 'hockey', 'league': 'whl', 'team_id': '144364', 'api_source': 'thesportsdb'}, + 'lethbridge hurricanes': {'sport': 'hockey', 'league': 'whl', 'team_id': '144363', 'api_source': 'thesportsdb'}, + 'lethbridge': {'sport': 'hockey', 'league': 'whl', 'team_id': '144363', 'api_source': 'thesportsdb'}, + 'swift current broncos': {'sport': 'hockey', 'league': 'whl', 'team_id': '144366', 'api_source': 'thesportsdb'}, + 'swift current': {'sport': 'hockey', 'league': 'whl', 'team_id': '144366', 'api_source': 'thesportsdb'}, + 'moose jaw warriors': {'sport': 'hockey', 'league': 'whl', 'team_id': '144368', 'api_source': 'thesportsdb'}, + 'moose jaw': {'sport': 'hockey', 'league': 'whl', 'team_id': '144368', 'api_source': 'thesportsdb'}, + 'regina pats': {'sport': 'hockey', 'league': 'whl', 'team_id': '144370', 'api_source': 'thesportsdb'}, + 'pats': {'sport': 'hockey', 'league': 'whl', 'team_id': '144370', 'api_source': 'thesportsdb'}, + 'regina': {'sport': 'hockey', 'league': 'whl', 'team_id': '144370', 'api_source': 'thesportsdb'}, + 'saskatoon blades': {'sport': 'hockey', 'league': 'whl', 'team_id': '144371', 'api_source': 'thesportsdb'}, + 'blades': {'sport': 'hockey', 'league': 'whl', 'team_id': '144371', 'api_source': 'thesportsdb'}, + 'saskatoon': {'sport': 'hockey', 'league': 'whl', 'team_id': '144371', 'api_source': 'thesportsdb'}, + 'prince albert raiders': {'sport': 'hockey', 'league': 'whl', 'team_id': '144369', 'api_source': 'thesportsdb'}, + 'prince albert': {'sport': 'hockey', 'league': 'whl', 'team_id': '144369', 'api_source': 'thesportsdb'}, + 'brandon wheat kings': {'sport': 'hockey', 'league': 'whl', 'team_id': '144367', 'api_source': 'thesportsdb'}, + 'wheat kings': {'sport': 'hockey', 'league': 'whl', 'team_id': '144367', 'api_source': 'thesportsdb'}, + 'brandon': {'sport': 'hockey', 'league': 'whl', 'team_id': '144367', 'api_source': 'thesportsdb'}, + + # MLS Teams + 'sounders': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9726'}, + 'seattle sounders': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9726'}, + + # NWSL Teams + 'reign': {'sport': 'soccer', 'league': 'usa.nwsl', 'team_id': '15363'}, + 'seattle reign': {'sport': 'soccer', 'league': 'usa.nwsl', 'team_id': '15363'}, + 'racing': {'sport': 'soccer', 'league': 'usa.nwsl', 'team_id': '20905'}, + 'racing louisville': {'sport': 'soccer', 'league': 'usa.nwsl', 'team_id': '20905'}, + 'louisville': {'sport': 'soccer', 'league': 'usa.nwsl', 'team_id': '20905'}, + 'atlanta united': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18418'}, + 'atl': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18418'}, + 'austin fc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '20906'}, + 'atx': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '20906'}, + 'cf montreal': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9720'}, + 'montreal': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9720'}, + 'mtl': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9720'}, + 'charlotte fc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '21300'}, + 'clt': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '21300'}, + 'chicago fire': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '182'}, + 'fire': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '182'}, + 'chi': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '182'}, + 'rapids': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '184'}, + 'colorado': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '184'}, + 'col': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '184'}, + 'crew': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '183'}, + 'columbus': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '183'}, + 'clb': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '183'}, + 'dc united': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '193'}, + 'dc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '193'}, + 'fc cincinnati': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18267'}, + 'cincinnati': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18267'}, + 'cin': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18267'}, + 'fc dallas': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '185'}, + 'dallas': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '185'}, + 'dal': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '185'}, + 'dynamo': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '6077'}, + 'houston': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '6077'}, + 'hou': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '6077'}, + 'inter miami': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '20232'}, + 'miami': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '20232'}, + 'mia': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '20232'}, + 'la galaxy': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '187'}, + 'galaxy': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '187'}, + 'la': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '187'}, + 'lafc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18966'}, + 'minnesota united': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '17362'}, + 'minnesota': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '17362'}, + 'min': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '17362'}, + 'nashville sc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18986'}, + 'nashville': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18986'}, + 'nsh': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18986'}, + 'revolution': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '189'}, + 'new england': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '189'}, + 'ne': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '189'}, + 'nyc fc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '17606'}, + 'nyc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '17606'}, + 'red bulls': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '190'}, + 'ny': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '190'}, + 'orlando city': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '12011'}, + 'orlando': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '12011'}, + 'orl': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '12011'}, + 'union': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '10739'}, + 'philadelphia': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '10739'}, + 'phi': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '10739'}, + 'timbers': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9723'}, + 'portland': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9723'}, + 'por': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9723'}, + 'real salt lake': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '4771'}, + 'salt lake': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '4771'}, + 'rsl': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '4771'}, + 'san diego fc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '22529'}, + 'san diego': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '22529'}, + 'sd': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '22529'}, + 'earthquakes': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '191'}, + 'san jose': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '191'}, + 'sj': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '191'}, + 'sporting kc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '186'}, + 'sporting kansas city': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '186'}, + 'skc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '186'}, + 'st louis city': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '21812'}, + 'st louis': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '21812'}, + 'stl': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '21812'}, + 'toronto fc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '7318'}, + 'toronto': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '7318'}, + 'tor': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '7318'}, + 'whitecaps': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9727'}, + 'vancouver': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9727'}, + 'van': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9727'}, + + # Premier League Teams + 'lfc': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '364'}, + 'liverpool': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '364'}, + 'manchester united': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '360'}, + 'man united': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '360'}, + 'arsenal': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '359'}, + 'chelsea': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '363'}, + 'manchester city': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '382'}, + 'man city': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '382'}, +} + +# Helper to check for women's leagues +WOMENS_LEAGUES = { + ('basketball', 'wnba'), + ('soccer', 'usa.nwsl'), + ('hockey', 'pwhl') +} + +# League mappings for league-wide queries +LEAGUE_MAPPINGS = { + # NFL + 'nfl': {'sport': 'football', 'league': 'nfl'}, + 'football': {'sport': 'football', 'league': 'nfl'}, + + # CFL + 'cfl': {'sport': 'football', 'league': 'cfl'}, + 'canadian football': {'sport': 'football', 'league': 'cfl'}, + + # MLB + 'mlb': {'sport': 'baseball', 'league': 'mlb'}, + 'baseball': {'sport': 'baseball', 'league': 'mlb'}, + + # NBA + 'nba': {'sport': 'basketball', 'league': 'nba'}, + 'basketball': {'sport': 'basketball', 'league': 'nba'}, + + # WNBA + 'wnba': {'sport': 'basketball', 'league': 'wnba'}, + + # NHL + 'nhl': {'sport': 'hockey', 'league': 'nhl'}, + 'hockey': {'sport': 'hockey', 'league': 'nhl'}, + + # PWHL + 'pwhl': {'sport': 'hockey', 'league': 'pwhl'}, + + # WHL + 'whl': {'sport': 'hockey', 'league': 'whl', 'league_id': '5160', 'api_source': 'thesportsdb'}, + + # MLS + 'mls': {'sport': 'soccer', 'league': 'usa.1'}, + 'soccer': {'sport': 'soccer', 'league': 'usa.1'}, + + # NWSL + 'nwsl': {'sport': 'soccer', 'league': 'usa.nwsl'}, + + # Premier League + 'epl': {'sport': 'soccer', 'league': 'eng.1'}, + 'premier league': {'sport': 'soccer', 'league': 'eng.1'} +} + +def format_clean_date_time(dt) -> str: + """Format date and time without leading zeros""" + month = dt.month + day = dt.day + minute = dt.minute + ampm = dt.strftime("%p") + + # Convert to 12-hour format + hour_12 = dt.hour + if hour_12 == 0: + hour_12 = 12 + elif hour_12 > 12: + hour_12 = hour_12 - 12 + + # Remove leading zeros + time_str = f"{month}/{day} {hour_12}:{minute:02d} {ampm}" + return time_str + +def format_clean_date(dt) -> str: + """Format date without leading zeros""" + month = dt.month + day = dt.day + return f"{month}/{day}" + +def get_team_abbreviation_from_name(team_name: str) -> str: + """Extract a short abbreviation from a team name + + Uses common city abbreviations for WHL teams. + """ + if not team_name: + return 'UNK' + + # WHL team abbreviation mappings + whl_abbreviations = { + 'seattle thunderbirds': 'SEA', + 'portland winterhawks': 'POR', + 'everett silvertips': 'EVE', + 'spokane chiefs': 'SPO', + 'vancouver giants': 'VAN', + 'kamloops blazers': 'KAM', + 'prince george cougars': 'PG', + 'kelowna rockets': 'KEL', + 'tri-city americans': 'TC', + 'wenatchee wild': 'WEN', + 'victoria royals': 'VIC', + 'edmonton oil kings': 'EDM', + 'calgary hitmen': 'CGY', + 'red deer rebels': 'RD', + 'medicine hat tigers': 'MH', + 'lethbridge hurricanes': 'LET', + 'swift current broncos': 'SC', + 'moose jaw warriors': 'MJ', + 'regina pats': 'REG', + 'saskatoon blades': 'SAS', + 'prince albert raiders': 'PA', + 'brandon wheat kings': 'BDN', + 'winnipeg ice': 'WPG', + } + + team_lower = team_name.lower() + if team_lower in whl_abbreviations: + return whl_abbreviations[team_lower] + + # Try to extract from city name (first one or two words) + words = team_name.lower().split() + if len(words) >= 2: + # Check for two-word cities first + two_word_city = f"{words[0]} {words[1]}" + # Use common city abbreviations + city_abbr = { + 'seattle': 'SEA', + 'portland': 'POR', + 'everett': 'EVE', + 'spokane': 'SPO', + 'vancouver': 'VAN', + 'kamloops': 'KAM', + 'prince george': 'PG', + 'prince albert': 'PA', + 'kelowna': 'KEL', + 'tri-city': 'TC', + 'tri city': 'TC', + 'tricity': 'TC', + 'wenatchee': 'WEN', + 'victoria': 'VIC', + 'edmonton': 'EDM', + 'calgary': 'CGY', + 'red deer': 'RD', + 'medicine hat': 'MH', + 'lethbridge': 'LET', + 'swift current': 'SC', + 'moose jaw': 'MJ', + 'regina': 'REG', + 'saskatoon': 'SAS', + 'brandon': 'BDN', + 'winnipeg': 'WPG', + } + + if two_word_city in city_abbr: + return city_abbr[two_word_city] + + # Then check for one-word cities + city = words[0] + if city in city_abbr: + return city_abbr[city] + + # Fallback for one-word city: use first 3 letters + if len(city) >= 3: + return city[:3].upper() + + # Final fallback: use first 3 letters of team name + return team_name[:3].upper() if len(team_name) >= 3 else team_name.upper() + +def is_womens_league(sport: str, league: str) -> bool: + """Check if the league is a women's league""" + return (sport, league) in WOMENS_LEAGUES + +def is_soccer(sport: str) -> bool: + """Check if the sport is soccer""" + return sport.lower() == 'soccer' + +def get_team_abbreviation(team_id: str, team_abbreviation: str, sport: str, league: str) -> str: + """Get team abbreviation, using -W suffix only for women's leagues""" + if is_womens_league(sport, league): + return WOMENS_TEAM_ABBREVIATIONS.get(team_id, team_abbreviation) + return team_abbreviation + +# Export all public functions and constants +__all__ = [ + 'SPORT_EMOJIS', 'WOMENS_TEAM_ABBREVIATIONS', 'TEAM_MAPPINGS', + 'WOMENS_LEAGUES', 'LEAGUE_MAPPINGS', 'is_womens_league', 'is_soccer', + 'get_team_abbreviation', 'get_team_abbreviation_from_name', + 'format_clean_date_time', 'format_clean_date' +] diff --git a/modules/clients/thesportsdb_client.py b/modules/clients/thesportsdb_client.py new file mode 100644 index 0000000..8fc1da8 --- /dev/null +++ b/modules/clients/thesportsdb_client.py @@ -0,0 +1,321 @@ +import aiohttp +import asyncio +import time +import logging +from datetime import datetime, timezone +from typing import List, Dict, Optional +from .sports_mappings import ( + get_team_abbreviation_from_name, format_clean_date, + format_clean_date_time, is_soccer +) + +class TheSportsDBClient: + """Client for TheSportsDB API with rate limiting and parsing logic""" + + BASE_URL = "https://www.thesportsdb.com/api/v1/json" + FREE_API_KEY = "123" # Free public API key + + def __init__(self, logger=None, timeout: int = 10, session: Optional[aiohttp.ClientSession] = None): + self.logger = logger or logging.getLogger(__name__) + self.timeout = aiohttp.ClientTimeout(total=timeout) + self.session = session + self.last_request_time = 0 + self.min_request_interval = 2.1 # Slightly more than 2 seconds for safety + + async def _get_session(self) -> aiohttp.ClientSession: + """Get or create an aiohttp session""" + if self.session is None or self.session.closed: + self.session = aiohttp.ClientSession(timeout=self.timeout) + return self.session + + async def _rate_limit(self): + """Enforce rate limiting asynchronously""" + current_time = time.time() + time_since_last = current_time - self.last_request_time + if time_since_last < self.min_request_interval: + sleep_time = self.min_request_interval - time_since_last + await asyncio.sleep(sleep_time) + self.last_request_time = time.time() + + async def search_team(self, team_name: str) -> Optional[Dict]: + """Search for a team by name""" + await self._rate_limit() + url = f"{self.BASE_URL}/{self.FREE_API_KEY}/searchteams.php" + params = {'t': team_name} + try: + session = await self._get_session() + async with session.get(url, params=params) as response: + response.raise_for_status() + data = await response.json() + teams = data.get('teams', []) + return teams[0] if teams else None + except Exception as e: + self.logger.error(f"TheSportsDB search_team error: {e}") + return None + + async def get_team_events_last(self, team_id: str, limit: int = 5) -> List[Dict]: + """Get last N events for a team""" + await self._rate_limit() + url = f"{self.BASE_URL}/{self.FREE_API_KEY}/eventslast.php" + params = {'id': team_id} + try: + session = await self._get_session() + async with session.get(url, params=params) as response: + response.raise_for_status() + data = await response.json() + events = data.get('results', []) + return events[:limit] if events else [] + except Exception as e: + self.logger.error(f"TheSportsDB get_team_events_last error: {e}") + return [] + + async def get_team_events_next(self, team_id: str, limit: int = 5) -> List[Dict]: + """Get next N events for a team""" + await self._rate_limit() + url = f"{self.BASE_URL}/{self.FREE_API_KEY}/eventsnext.php" + params = {'id': team_id} + try: + session = await self._get_session() + async with session.get(url, params=params) as response: + response.raise_for_status() + data = await response.json() + events = data.get('events', []) + return events[:limit] if events else [] + except Exception as e: + self.logger.error(f"TheSportsDB get_team_events_next error: {e}") + return [] + + async def fetch_team_games(self, sport: str, league: str, team_id: str) -> List[Dict]: + """Fetch and parse team games (last and next)""" + last_events_task = self.get_team_events_last(team_id, limit=5) + next_events_task = self.get_team_events_next(team_id, limit=5) + + last_events, next_events = await asyncio.gather(last_events_task, next_events_task) + + all_games = [] + # Parse last events (completed games) + for event in last_events: + game_data = self.parse_event(event, team_id, sport, league) + if game_data: + all_games.append(game_data) + + # Parse next events (upcoming games) + for event in next_events: + game_data = self.parse_event(event, team_id, sport, league) + if game_data: + all_games.append(game_data) + + return all_games + + async def fetch_team_schedule(self, sport: str, league: str, team_id: str) -> List[Dict]: + """Fetch upcoming scheduled games for a team""" + next_events = await self.get_team_events_next(team_id, limit=10) + + upcoming_games = [] + for event in next_events: + game_data = self.parse_event(event, team_id, sport, league) + if game_data: + upcoming_games.append(game_data) + + return upcoming_games + + def parse_event(self, event: Dict, team_id: str, sport: str, league: str) -> Optional[Dict]: + """Parse a TheSportsDB event and return structured data with timestamp for sorting""" + try: + # Extract team info + home_team = event.get('strHomeTeam', '') + away_team = event.get('strAwayTeam', '') + home_score = event.get('intHomeScore', '') + away_score = event.get('intAwayScore', '') + status = event.get('strStatus', 'UNKNOWN') + timestamp_str = event.get('strTimestamp', '') + date_str = event.get('dateEvent', '') + time_str = event.get('strTime', '') + + # Determine if our team is home or away + our_team_id = str(team_id) + event_home_id = str(event.get('idHomeTeam', '')) + is_home = (event_home_id == our_team_id) + + # Get team abbreviations + home_abbr = get_team_abbreviation_from_name(home_team) + away_abbr = get_team_abbreviation_from_name(away_team) + + # Get timestamp for sorting + timestamp = 0 + event_timestamp = None + if timestamp_str: + try: + # fromisoformat handles 'Z' in Python 3.11+, but we force UTC if naive + dt = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + event_timestamp = dt.timestamp() + timestamp = event_timestamp + except: + if date_str and time_str: + try: + dt_str = f"{date_str} {time_str}" + dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S") + dt = dt.replace(tzinfo=timezone.utc) + event_timestamp = dt.timestamp() + timestamp = event_timestamp + except: pass + + formatted = "" + if status == 'Match Finished': + date_suffix = "" + if event_timestamp: + dt = datetime.fromtimestamp(event_timestamp, tz=timezone.utc).astimezone() + if dt.date() != datetime.now().date(): + date_suffix = f", {format_clean_date(dt)}" + + if home_score and away_score: + if is_soccer(sport): + formatted = f"@{home_abbr} {home_score}-{away_score} {away_abbr} (F{date_suffix})" + elif is_home: + formatted = f"{away_abbr} {away_score}-{home_score} @{home_abbr} (F{date_suffix})" + else: + formatted = f"@{home_abbr} {home_score}-{away_score} {away_abbr} (F{date_suffix})" + else: + if is_soccer(sport): + formatted = f"@{home_abbr} vs. {away_abbr} (Final{date_suffix})" + else: + formatted = f"{away_abbr} vs. {home_abbr} (Final{date_suffix})" + timestamp = 9999999998 + else: + # Scheduled or TBD + if event_timestamp: + dt = datetime.fromtimestamp(event_timestamp, tz=timezone.utc).astimezone() + time_str_formatted = format_clean_date_time(dt) + if is_soccer(sport): + formatted = f"@{home_abbr} vs. {away_abbr} ({time_str_formatted})" + else: + formatted = f"{away_abbr} @ {home_abbr} ({time_str_formatted})" + else: + if is_soccer(sport): + formatted = f"@{home_abbr} vs. {away_abbr} (TBD)" + else: + formatted = f"{away_abbr} @ {home_abbr} (TBD)" + timestamp = 9999999999 + + return { + 'id': event.get('idEvent'), + 'timestamp': timestamp, + 'event_timestamp': event_timestamp, + 'formatted': formatted, + 'sport': sport, + 'league': league, + 'status': status + } + except Exception as e: + self.logger.error(f"Error parsing TheSportsDB event {event.get('idEvent')}: {e}") + return None + async def get_league_teams(self, league_id: str) -> List[Dict]: + """Get all teams in a league""" + await self._rate_limit() + url = f"{self.BASE_URL}/{self.FREE_API_KEY}/lookup_all_teams.php" + params = {'id': league_id} + try: + session = await self._get_session() + async with session.get(url, params=params) as response: + response.raise_for_status() + data = await response.json() + teams = data.get('teams', []) + return teams if teams else [] + except Exception as e: + self.logger.error(f"TheSportsDB get_league_teams error: {e}") + return [] + + async def get_league_events_next(self, league_id: str, limit: int = 10) -> List[Dict]: + """Get next N events for a league""" + await self._rate_limit() + url = f"{self.BASE_URL}/{self.FREE_API_KEY}/eventsnextleague.php" + params = {'id': league_id} + try: + session = await self._get_session() + async with session.get(url, params=params) as response: + response.raise_for_status() + data = await response.json() + events = data.get('events', []) + return events[:limit] if events else [] + except Exception as e: + self.logger.error(f"TheSportsDB get_league_events_next error: {e}") + return [] + + async def get_league_events_past(self, league_id: str, limit: int = 10) -> List[Dict]: + """Get past N events for a league""" + await self._rate_limit() + url = f"{self.BASE_URL}/{self.FREE_API_KEY}/eventspastleague.php" + params = {'id': league_id} + try: + session = await self._get_session() + async with session.get(url, params=params) as response: + response.raise_for_status() + data = await response.json() + events = data.get('results', []) + return events[:limit] if events else [] + except Exception as e: + self.logger.error(f"TheSportsDB get_league_events_past error: {e}") + return [] + + async def get_events_by_day(self, date_str: str, league_id: str = None) -> List[Dict]: + """Get events for a specific day""" + await self._rate_limit() + url = f"{self.BASE_URL}/{self.FREE_API_KEY}/eventsday.php" + params = {'d': date_str} + if league_id: + params['l'] = league_id + try: + session = await self._get_session() + async with session.get(url, params=params) as response: + response.raise_for_status() + data = await response.json() + events = data.get('events', []) + if events is None: return [] + return events if isinstance(events, list) else [] + except Exception as e: + self.logger.error(f"TheSportsDB get_events_by_day error: {e}") + return [] + + async def fetch_league_scores(self, sport: str, league_name: str, league_id: str) -> List[Dict]: + """Fetch and parse league scores from multiple sources""" + from datetime import timedelta + + # Get today's date and next few days + today = datetime.now().date() + date_strings = [today.strftime('%Y-%m-%d')] + for i in range(1, 7): # Next 6 days + date_strings.append((today + timedelta(days=i)).strftime('%Y-%m-%d')) + + # Fetch events from multiple sources in parallel + next_events_task = self.get_league_events_next(league_id, limit=15) + past_events_task = self.get_league_events_past(league_id, limit=5) + day_events_tasks = [self.get_events_by_day(d, league_id) for d in date_strings] + + # Wait for all requests + results = await asyncio.gather(next_events_task, past_events_task, *day_events_tasks) + next_events = results[0] + past_events = results[1] + day_events_list = results[2:] + + # Combine and parse + all_event_data = [] + seen_event_ids = set() + + # Helper to add parsed events + def add_parsed(events): + for event in events: + event_id = event.get('idEvent') + if event_id and event_id not in seen_event_ids: + parsed = self.parse_event(event, "", sport, league_name) + if parsed: + all_event_data.append(parsed) + seen_event_ids.add(event_id) + + add_parsed(next_events) + add_parsed(past_events) + for day_events in day_events_list: + add_parsed(day_events) + + return all_event_data diff --git a/modules/commands/advert_command.py b/modules/commands/advert_command.py index ce0a4b9..3b34940 100644 --- a/modules/commands/advert_command.py +++ b/modules/commands/advert_command.py @@ -5,6 +5,7 @@ Handles the 'advert' command for sending flood adverts """ import time +from typing import Any from .base_command import BaseCommand from ..models import MeshMessage @@ -25,6 +26,14 @@ class AdvertCommand(BaseCommand): cooldown_seconds = 3600 # 1 hour category = "special" + def __init__(self, bot: Any): + """Initialize the advert command. + + Args: + bot: The bot instance. + """ + super().__init__(bot) + def get_help_text(self) -> str: """Get help text for the advert command. diff --git a/modules/commands/greeter_command.py b/modules/commands/greeter_command.py index 1be1218..d8d9bf3 100644 --- a/modules/commands/greeter_command.py +++ b/modules/commands/greeter_command.py @@ -8,7 +8,7 @@ import sqlite3 import time import asyncio from datetime import datetime, timedelta -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any, List, Tuple from .base_command import BaseCommand from ..models import MeshMessage @@ -22,7 +22,12 @@ class GreeterCommand(BaseCommand): description = "Greets users on their first public channel message (once globally by default, or per-channel if configured)" category = "system" - def __init__(self, bot): + def __init__(self, bot: Any): + """Initialize the greeter command. + + Args: + bot: The bot instance. + """ super().__init__(bot) self._init_greeter_tables() self._load_config() @@ -114,8 +119,8 @@ class GreeterCommand(BaseCommand): import traceback self.logger.error(traceback.format_exc()) - def _load_config(self): - """Load configuration for greeter command""" + def _load_config(self) -> None: + """Load configuration for greeter command.""" self.enabled = self.get_config_value('Greeter_Command', 'enabled', fallback=False, value_type='bool') self.greeting_message = self.get_config_value('Greeter_Command', 'greeting_message', fallback='Welcome to the mesh, {sender}!') @@ -179,8 +184,8 @@ class GreeterCommand(BaseCommand): self.levenshtein_distance = self.get_config_value('Greeter_Command', 'levenshtein_distance', fallback=0, value_type='int') - def _init_greeter_tables(self): - """Initialize database tables for greeter tracking""" + def _init_greeter_tables(self) -> None: + """Initialize database tables for greeter tracking.""" try: with self.bot.db_manager.get_connection() as conn: cursor = conn.cursor() @@ -226,8 +231,8 @@ class GreeterCommand(BaseCommand): self.logger.error(f"Failed to initialize greeter tables: {e}") raise - def _check_rollout_period(self): - """Check if we're in a rollout period and mark active users if needed""" + def _check_rollout_period(self) -> None: + """Check if we're in a rollout period and mark active users if needed.""" if not self.enabled: return @@ -281,8 +286,12 @@ class GreeterCommand(BaseCommand): except Exception as e: self.logger.error(f"Error checking rollout period: {e}") - def _mark_active_users_as_greeted(self, rollout_id: int): - """Mark all users who have posted on public channels during rollout period as greeted""" + def _mark_active_users_as_greeted(self, rollout_id: int) -> None: + """Mark all users who have posted on public channels during rollout period as greeted. + + Args: + rollout_id: The ID of the active rollout. + """ try: with self.bot.db_manager.get_connection() as conn: cursor = conn.cursor() @@ -355,17 +364,16 @@ class GreeterCommand(BaseCommand): self.logger.error(f"Error marking active users as greeted: {e}") def backfill_greeted_users(self, lookback_days: Optional[int] = None) -> Dict[str, Any]: - """ - Backfill greeted_users table from historical message_stats data + """Backfill greeted_users table from historical message_stats data. This allows marking all users who have posted on public channels in the past, which can shorten or eliminate the rollout period. Args: - lookback_days: Number of days to look back (None = all time) + lookback_days: Number of days to look back (None = all time). Returns: - Dictionary with backfill results (marked_count, total_users, etc.) + Dict[str, Any]: Dictionary with backfill results (marked_count, total_users, etc.) """ if not self.enabled: self.logger.warning("Greeter is disabled - cannot backfill") @@ -464,15 +472,14 @@ class GreeterCommand(BaseCommand): } def start_rollout(self, days: Optional[int] = None, backfill_first: bool = True) -> bool: - """ - Start a rollout period where all active users are marked as greeted + """Start a rollout period where all active users are marked as greeted. Args: - days: Number of days for rollout period (uses config default if None) - backfill_first: If True, backfill from historical data before starting rollout + days: Number of days for rollout period (uses config default if None). + backfill_first: If True, backfill from historical data before starting rollout. Returns: - True if rollout started successfully + bool: True if rollout started successfully. """ if not self.enabled: self.logger.warning("Greeter is disabled - cannot start rollout") @@ -521,15 +528,14 @@ class GreeterCommand(BaseCommand): return False def _levenshtein_distance(self, s1: str, s2: str) -> int: - """ - Calculate Levenshtein distance between two strings + """Calculate Levenshtein distance between two strings. Args: - s1: First string - s2: Second string + s1: First string. + s2: Second string. Returns: - Levenshtein distance (number of edits needed) + int: Levenshtein distance (number of edits needed). """ if len(s1) < len(s2): return self._levenshtein_distance(s2, s1) @@ -550,15 +556,14 @@ class GreeterCommand(BaseCommand): return previous_row[-1] def _find_similar_greeted_user(self, sender_id: str, channel: str) -> Optional[str]: - """ - Find if a user with a similar name (within Levenshtein distance) has been greeted + """Find if a user with a similar name has been greeted. Args: - sender_id: The user's ID to check - channel: The channel name (used only if per_channel_greetings is True) + sender_id: The user's ID to check. + channel: The channel name (used only if per_channel_greetings is True). Returns: - The greeted sender_id if a similar one is found, None otherwise + Optional[str]: The greeted sender_id if a similar one is found, None otherwise. """ if self.levenshtein_distance <= 0: return None @@ -595,15 +600,14 @@ class GreeterCommand(BaseCommand): return None def has_been_greeted(self, sender_id: str, channel: str) -> bool: - """ - Check if a user has been greeted (with optional Levenshtein distance matching) + """Check if a user has been greeted. Args: - sender_id: The user's ID - channel: The channel name (used only if per_channel_greetings is True) + sender_id: The user's ID. + channel: The channel name (used only if per_channel_greetings is True). Returns: - True if user has been greeted (globally or on this channel, depending on config) + bool: True if user has been greeted (globally or on this channel), False otherwise. """ try: with self.bot.db_manager.get_connection() as conn: @@ -638,19 +642,16 @@ class GreeterCommand(BaseCommand): return False def mark_as_greeted(self, sender_id: str, channel: str) -> bool: - """ - Mark a user as greeted atomically. + """Mark a user as greeted atomically. Uses INSERT OR IGNORE with UNIQUE constraint to handle race conditions. - Returns True if user was successfully marked (or already marked). - Returns False only on actual errors (not on duplicate attempts). Args: - sender_id: The user's ID - channel: The channel name (stored only if per_channel_greetings is True) + sender_id: The user's ID. + channel: The channel name (stored only if per_channel_greetings is True). Returns: - True if user was marked (or already marked), False on error + bool: True if user was marked (or already marked), False on error. """ try: self.logger.debug(f"Marking {sender_id} as greeted (channel: {channel})") @@ -780,7 +781,11 @@ class GreeterCommand(BaseCommand): return False def get_greeted_users_count(self) -> int: - """Get count of users who have been greeted (for verification)""" + """Get count of users who have been greeted. + + Returns: + int: The total count of greeted users. + """ try: with self.bot.db_manager.get_connection() as conn: cursor = conn.cursor() @@ -791,8 +796,8 @@ class GreeterCommand(BaseCommand): self.logger.error(f"Error getting greeted users count: {e}") return 0 - def _cleanup_duplicate_greetings(self): - """Remove duplicate entries from greeted_users table, keeping the earliest (first) greeting""" + def _cleanup_duplicate_greetings(self) -> None: + """Remove duplicate entries from greeted_users table.""" try: with self.bot.db_manager.get_connection() as conn: cursor = conn.cursor() @@ -852,7 +857,14 @@ class GreeterCommand(BaseCommand): # Don't raise - allow initialization to continue even if cleanup fails def get_recent_greeted_users(self, limit: int = 10) -> List[Dict[str, Any]]: - """Get recent greeted users (for verification) - ordered by first greeting time""" + """Get recent greeted users. + + Args: + limit: Maximum number of users to return. + + Returns: + List[Dict[str, Any]]: A list of dictionaries containing greeted user info. + """ try: with self.bot.db_manager.get_connection() as conn: cursor = conn.cursor() @@ -871,7 +883,11 @@ class GreeterCommand(BaseCommand): return [] async def _get_mesh_info(self) -> Dict[str, Any]: - """Get mesh network information for greeting""" + """Get mesh network information for greeting. + + Returns: + Dict[str, Any]: A dictionary containing mesh statistics. + """ info = { 'total_contacts': 0, 'repeaters': 0, @@ -933,30 +949,28 @@ class GreeterCommand(BaseCommand): return info def _get_greeting_for_channel(self, channel: str) -> str: - """ - Get greeting message for a specific channel + """Get greeting message for a specific channel. Args: - channel: Channel name + channel: Channel name. Returns: - Greeting message template for the channel, or default if not specified + str: Greeting message template for the channel, or default if not specified. """ if channel and channel.lower() in self.channel_greetings: return self.channel_greetings[channel.lower()]['greeting'] return self.greeting_message - async def _format_greeting_parts(self, sender_id: str, channel: str = None, mesh_info: Optional[Dict[str, Any]] = None) -> list: - """ - Format greeting message parts with mesh information + async def _format_greeting_parts(self, sender_id: str, channel: Optional[str] = None, mesh_info: Optional[Dict[str, Any]] = None) -> List[str]: + """Format greeting message parts with mesh information. Args: - sender_id: The user's ID - channel: Channel name (for channel-specific greetings) - mesh_info: Optional mesh info dict (will be fetched if None) + sender_id: The user's ID. + channel: Channel name (for channel-specific greetings). + mesh_info: Optional mesh info dict (will be fetched if None). Returns: - List of greeting message strings (for multi-part greetings) + List[str]: List of greeting message strings (for multi-part greetings). """ if mesh_info is None: mesh_info = await self._get_mesh_info() @@ -993,15 +1007,33 @@ class GreeterCommand(BaseCommand): return formatted_parts def matches_keyword(self, message: MeshMessage) -> bool: - """Greeter doesn't match keywords - it's triggered automatically""" + """Greeter doesn't match keywords - it's triggered automatically. + + Args: + message: The message to check. + + Returns: + bool: Always False. + """ return False def matches_custom_syntax(self, message: MeshMessage) -> bool: - """Greeter doesn't match custom syntax""" + """Greeter doesn't match custom syntax. + + Args: + message: The message to check. + + Returns: + bool: Always False. + """ return False def _is_rollout_active(self) -> bool: - """Check if there's an active rollout period""" + """Check if there's an active rollout period. + + Returns: + bool: True if a rollout is active, False otherwise. + """ try: with self.bot.db_manager.get_connection() as conn: cursor = conn.cursor() @@ -1051,16 +1083,15 @@ class GreeterCommand(BaseCommand): return False def _check_human_greeting(self, new_user_id: str, channel: str, since_timestamp: int) -> bool: - """ - Check if a human has greeted the new user by mentioning their name in a message + """Check if a human has greeted the new user. Args: - new_user_id: The new user's ID to check for - channel: The channel to check - since_timestamp: Only check messages after this timestamp + new_user_id: The new user's ID to check for. + channel: The channel to check. + since_timestamp: Only check messages after this timestamp. Returns: - True if a human (not the new user) has mentioned the new user's name + bool: True if a human has mentioned the new user, False otherwise. """ if not self.defer_to_human_greeting: return False @@ -1115,8 +1146,13 @@ class GreeterCommand(BaseCommand): self.logger.error(f"Error checking for human greeting: {e}") return False - def _cancel_pending_greeting(self, sender_id: str, channel: str): - """Cancel a pending greeting if it exists""" + def _cancel_pending_greeting(self, sender_id: str, channel: str) -> None: + """Cancel a pending greeting if it exists. + + Args: + sender_id: The user's ID. + channel: The channel name. + """ key = (sender_id, channel) if key in self.pending_greetings: task = self.pending_greetings[key] @@ -1125,12 +1161,11 @@ class GreeterCommand(BaseCommand): self.logger.info(f"Cancelled pending greeting for {sender_id} on {channel}") del self.pending_greetings[key] - async def _send_delayed_greeting(self, message: MeshMessage): - """ - Send a greeting after the dead air delay, checking for human greetings during the delay + async def _send_delayed_greeting(self, message: MeshMessage) -> None: + """Send a greeting after the dead air delay. Args: - message: The original message that triggered the greeting + message: The original message that triggered the greeting. """ key = (message.sender_id, message.channel) original_timestamp = message.timestamp or int(time.time()) @@ -1182,14 +1217,13 @@ class GreeterCommand(BaseCommand): del self.pending_greetings[key] async def _send_greeting(self, message: MeshMessage) -> bool: - """ - Actually send the greeting message (extracted from execute for reuse) + """Actually send the greeting message. Args: - message: The message that triggered the greeting + message: The message that triggered the greeting. Returns: - True if greeting was sent successfully + bool: True if greeting was sent successfully. """ try: # Format greeting parts (may be single or multi-part) @@ -1227,9 +1261,13 @@ class GreeterCommand(BaseCommand): return False def should_execute(self, message: MeshMessage) -> bool: - """ - Check if greeter should execute for this message - Only executes for public channel messages (not DMs) on monitored channels + """Check if greeter should execute for this message. + + Args: + message: The message to check. + + Returns: + bool: True if the greeter should execute, False otherwise. """ if not self.enabled: return False @@ -1277,7 +1315,14 @@ class GreeterCommand(BaseCommand): return True async def execute(self, message: MeshMessage) -> bool: - """Execute the greeter command - greet the user on their first public message""" + """Execute the greeter command. + + Args: + message: The message triggering the greeting. + + Returns: + bool: True if executed successfully, False otherwise. + """ try: # Double-check we should greet (race condition protection) if not self.should_execute(message): @@ -1353,13 +1398,11 @@ class GreeterCommand(BaseCommand): self.logger.error(f"Error executing greeter command: {e}") return False - def check_message_for_human_greeting(self, message: MeshMessage): - """ - Check if an incoming message should cancel a pending greeting - Called from message handler when new messages arrive + def check_message_for_human_greeting(self, message: MeshMessage) -> None: + """Check if an incoming message should cancel a pending greeting. Args: - message: The incoming message to check + message: The incoming message to check. """ if not self.defer_to_human_greeting or not self.dead_air_delay_seconds > 0: return @@ -1397,6 +1440,11 @@ class GreeterCommand(BaseCommand): self.mark_as_greeted(key[0], key[1]) def get_help_text(self) -> str: + """Get help text for the greeter command. + + Returns: + str: The help text for this command. + """ mode = "per-channel" if self.per_channel_greetings else "global (once total)" return f"Greeter automatically welcomes new users on public channels ({mode} mode). Configure in [Greeter_Command] section." diff --git a/modules/commands/hacker_command.py b/modules/commands/hacker_command.py index 4e95d7f..6321ac4 100644 --- a/modules/commands/hacker_command.py +++ b/modules/commands/hacker_command.py @@ -5,6 +5,7 @@ Responds to Linux commands with hilarious supervillain mainframe error messages """ import random +from typing import Any from .base_command import BaseCommand from ..models import MeshMessage @@ -21,15 +22,32 @@ class HackerCommand(BaseCommand): description = "Simulates hacking a supervillain's mainframe with hilarious error messages" category = "fun" - def __init__(self, bot): + def __init__(self, bot: Any): + """Initialize the hacker command. + + Args: + bot: The bot instance. + """ super().__init__(bot) self.enabled = self.get_config_value('Hacker_Command', 'hacker_enabled', fallback=False, value_type='bool') def get_help_text(self) -> str: + """Get help text for the hacker command. + + Returns: + str: The help text for this command. + """ return self.description async def execute(self, message: MeshMessage) -> bool: - """Execute the hacker command""" + """Execute the hacker command. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ if not self.enabled: return False @@ -45,7 +63,14 @@ class HackerCommand(BaseCommand): return await self.send_response(message, error_msg) def get_hacker_error(self, command: str) -> str: - """Get a hilarious error message for the given command""" + """Get a hilarious error message for the given command. + + Args: + command: The command that triggered the error. + + Returns: + str: A randomized hacker-themed error message. + """ command_lower = command.lower() # Try to get errors from translations, fallback to hardcoded if not available @@ -440,7 +465,14 @@ class HackerCommand(BaseCommand): return get_random_error('commands.hacker.generic_errors', fallback) def matches_keyword(self, message: MeshMessage) -> bool: - """Override to check for command matches (exact for some, prefix for others)""" + """Check if message matches any of the hacker keywords. + + Args: + message: The received message. + + Returns: + bool: True if it matches, False otherwise. + """ if not self.enabled: return False diff --git a/modules/commands/hello_command.py b/modules/commands/hello_command.py index 4749bc7..4a4a0d4 100644 --- a/modules/commands/hello_command.py +++ b/modules/commands/hello_command.py @@ -5,6 +5,7 @@ Responds to various greetings with robot-themed responses """ import random +from typing import Any, List, Dict from .base_command import BaseCommand from ..models import MeshMessage @@ -18,14 +19,19 @@ class HelloCommand(BaseCommand): description = "Responds to greetings with robot-themed responses" category = "basic" - def __init__(self, bot): + def __init__(self, bot: Any): + """Initialize the hello command. + + Args: + bot: The bot instance. + """ super().__init__(bot) # Fallback arrays if translations not available self._init_fallback_arrays() - def _init_fallback_arrays(self): - """Initialize fallback arrays for when translations are not available""" + def _init_fallback_arrays(self) -> None: + """Initialize fallback arrays for when translations are not available.""" # Time-neutral greeting openings self.greeting_openings_fallback = [ "Hello", "Greetings", "Salutations", "Hi", "Hey", "Howdy", "Yo", "Sup", @@ -171,57 +177,100 @@ class HelloCommand(BaseCommand): ] } - def get_greeting_openings(self) -> list: - """Get greeting openings from translations or fallback""" + def get_greeting_openings(self) -> List[str]: + """Get greeting openings from translations or fallback. + + Returns: + List[str]: A list of greeting opening strings. + """ openings = self.translate_get_value('commands.hello.greeting_openings') if openings and isinstance(openings, list) and len(openings) > 0: return openings return self.greeting_openings_fallback - def get_morning_greetings(self) -> list: - """Get morning greetings from translations or fallback""" + def get_morning_greetings(self) -> List[str]: + """Get morning greetings from translations or fallback. + + Returns: + List[str]: A list of morning greeting strings. + """ greetings = self.translate_get_value('commands.hello.morning_greetings') if greetings and isinstance(greetings, list) and len(greetings) > 0: return greetings return self.morning_greetings_fallback - def get_afternoon_greetings(self) -> list: - """Get afternoon greetings from translations or fallback""" + def get_afternoon_greetings(self) -> List[str]: + """Get afternoon greetings from translations or fallback. + + Returns: + List[str]: A list of afternoon greeting strings. + """ greetings = self.translate_get_value('commands.hello.afternoon_greetings') if greetings and isinstance(greetings, list) and len(greetings) > 0: return greetings return self.afternoon_greetings_fallback - def get_evening_greetings(self) -> list: - """Get evening greetings from translations or fallback""" + def get_evening_greetings(self) -> List[str]: + """Get evening greetings from translations or fallback. + + Returns: + List[str]: A list of evening greeting strings. + """ greetings = self.translate_get_value('commands.hello.evening_greetings') if greetings and isinstance(greetings, list) and len(greetings) > 0: return greetings return self.evening_greetings_fallback - def get_human_descriptors(self) -> list: - """Get human descriptors from translations or fallback""" + def get_human_descriptors(self) -> List[str]: + """Get human descriptors from translations or fallback. + + Returns: + List[str]: A list of human descriptor strings. + """ descriptors = self.translate_get_value('commands.hello.human_descriptors') if descriptors and isinstance(descriptors, list) and len(descriptors) > 0: return descriptors return self.human_descriptors_fallback - def get_emoji_responses(self) -> dict: - """Get emoji responses from translations or fallback""" + def get_emoji_responses(self) -> Dict[str, List[str]]: + """Get emoji responses from translations or fallback. + + Returns: + Dict[str, List[str]]: A dictionary mapping emojis to lists of response strings. + """ responses = self.translate_get_value('commands.hello.emoji_responses') if responses and isinstance(responses, dict) and len(responses) > 0: return responses return self.emoji_responses_fallback def get_help_text(self) -> str: + """Get help text for the hello command. + + Returns: + str: The help text for this command. + """ return self.translate('commands.hello.help') def matches_custom_syntax(self, message: MeshMessage) -> bool: - """Check if message contains only defined emojis""" + """Check if message contains only defined emojis. + + Args: + message: The message to check. + + Returns: + bool: True if it's an emoji-only message, False otherwise. + """ return self.is_emoji_only_message(message.content) async def execute(self, message: MeshMessage) -> bool: - """Execute the hello command""" + """Execute the hello command. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ # Get bot name from config bot_name = self.bot.config.get('Bot', 'bot_name', fallback='Bot') diff --git a/modules/commands/help_command.py b/modules/commands/help_command.py index 456e940..5b9b6ff 100644 --- a/modules/commands/help_command.py +++ b/modules/commands/help_command.py @@ -6,6 +6,7 @@ Provides help information for commands and general usage import sqlite3 from collections import defaultdict +from typing import Any, Optional from .base_command import BaseCommand from ..models import MeshMessage diff --git a/modules/commands/hfcond_command.py b/modules/commands/hfcond_command.py index 905c6dd..9c2b942 100644 --- a/modules/commands/hfcond_command.py +++ b/modules/commands/hfcond_command.py @@ -20,6 +20,7 @@ class HfcondCommand(BaseCommand): keywords = ['hfcond'] description = "Get HF band conditions for ham radio" category = "solar" + requires_internet = True # Requires internet access for hamqsl.com API def __init__(self, bot): """Initialize the hfcond command. diff --git a/modules/commands/joke_command.py b/modules/commands/joke_command.py index e2f802e..9755dfd 100644 --- a/modules/commands/joke_command.py +++ b/modules/commands/joke_command.py @@ -7,7 +7,7 @@ Provides clean, family-friendly jokes from the JokeAPI import aiohttp import asyncio import logging -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List from .base_command import BaseCommand from ..models import MeshMessage @@ -40,7 +40,12 @@ class JokeCommand(BaseCommand): BLACKLIST_FLAGS = "nsfw,religious,political,racist,sexist,explicit" TIMEOUT = 10 # seconds - def __init__(self, bot): + def __init__(self, bot: Any): + """Initialize the joke command. + + Args: + bot: The bot instance. + """ super().__init__(bot) # Load configuration @@ -61,7 +66,14 @@ class JokeCommand(BaseCommand): return f"Usage: joke [category] - Get a random joke or from categories: {categories}" def matches_keyword(self, message: MeshMessage) -> bool: - """Check if message starts with a joke keyword""" + """Check if message starts with a joke keyword. + + Args: + message: The message to check. + + Returns: + bool: True if a joke keyword matches, False otherwise. + """ content = message.content.strip() if content.startswith('!'): content = content[1:].strip() @@ -123,7 +135,14 @@ class JokeCommand(BaseCommand): return None async def execute(self, message: MeshMessage) -> bool: - """Execute the joke command""" + """Execute the joke command. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ content = message.content.strip() # Parse the command to extract category @@ -166,8 +185,15 @@ class JokeCommand(BaseCommand): await self.send_response(message, "Sorry, something went wrong getting a joke!") return True - async def get_joke_from_api(self, category: str = None) -> dict: - """Get a joke from the JokeAPI""" + async def get_joke_from_api(self, category: Optional[str] = None) -> Optional[Dict[str, Any]]: + """Get a joke from the JokeAPI. + + Args: + category: The joke category to fetch. + + Returns: + Optional[Dict[str, Any]]: The joke data from the API, or None if it fails. + """ try: # Build the API URL # For dark jokes, don't use safe-mode since users expect dark humor @@ -251,8 +277,13 @@ class JokeCommand(BaseCommand): self.logger.warning(f"Could not get short joke after {max_attempts} attempts") return joke_data - async def send_joke_with_length_handling(self, message: MeshMessage, joke_data: Dict[str, Any]): - """Send joke with length handling - split if necessary""" + async def send_joke_with_length_handling(self, message: MeshMessage, joke_data: Dict[str, Any]) -> None: + """Send joke with length handling - split if necessary. + + Args: + message: The original message to respond to. + joke_data: The joke data from the API. + """ joke_text = self.format_joke(joke_data) if len(joke_text) <= 130: @@ -272,8 +303,15 @@ class JokeCommand(BaseCommand): # Cannot be split properly, send as single message (user will see truncation) await self.send_response(message, joke_text) - def split_joke(self, joke_text: str) -> list: - """Split a long joke at a logical point""" + def split_joke(self, joke_text: str) -> List[str]: + """Split a long joke at a logical point. + + Args: + joke_text: The full text of the joke. + + Returns: + List[str]: A list of joke parts. + """ # Remove emoji for splitting clean_joke = joke_text[2:] if joke_text.startswith('🎭 ') else joke_text @@ -307,8 +345,15 @@ class JokeCommand(BaseCommand): return [f"🎭 {part1}", f"🎭 {part2}"] - def format_joke(self, joke_data: dict) -> str: - """Format the joke data into a readable string""" + def format_joke(self, joke_data: Dict[str, Any]) -> str: + """Format the joke data into a readable string. + + Args: + joke_data: The joke data from the API. + + Returns: + str: The formatted joke string. + """ try: joke_type = joke_data.get('type', 'single') diff --git a/modules/commands/prefix_command.py b/modules/commands/prefix_command.py index 55114bd..0d62116 100644 --- a/modules/commands/prefix_command.py +++ b/modules/commands/prefix_command.py @@ -27,7 +27,12 @@ class PrefixCommand(BaseCommand): cooldown_seconds = 2 requires_internet = False # Will be set to True in __init__ if API is configured - def __init__(self, bot): + def __init__(self, bot: Any): + """Initialize the prefix command. + + Args: + bot: The bot instance. + """ super().__init__(bot) # Get API URL from config, no fallback to regional API self.api_url = self.bot.config.get('External_Data', 'repeater_prefix_api_url', fallback="") @@ -63,6 +68,11 @@ class PrefixCommand(BaseCommand): ) def get_help_text(self) -> str: + """Get help text for the prefix command. + + Returns: + str: The help text for this command. + """ location_note = self.translate('commands.prefix.location_note') if self.show_repeater_locations else "" if not self.api_url or self.api_url.strip() == "": return self.translate('commands.prefix.help_no_api', location_note=location_note) @@ -81,7 +91,14 @@ class PrefixCommand(BaseCommand): return content_lower == 'prefix' or content_lower.startswith('prefix ') async def execute(self, message: MeshMessage) -> bool: - """Execute the prefix command""" + """Execute the prefix command. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ content = message.content.strip() # Handle exclamation prefix @@ -258,8 +275,8 @@ class PrefixCommand(BaseCommand): # Return original API data if enhancement fails return api_data - async def refresh_cache(self): - """Refresh the cache from the API""" + async def refresh_cache(self) -> None: + """Refresh the cache from the API.""" try: # Check if API URL is configured if not self.api_url or self.api_url.strip() == "": @@ -546,7 +563,15 @@ class PrefixCommand(BaseCommand): return [], 0, False def format_free_prefixes_response(self, free_prefixes: List[str], total_free: int) -> str: - """Format the free prefixes response""" + """Format the free prefixes response. + + Args: + free_prefixes: List of free prefixes to display. + total_free: Total count of free prefixes. + + Returns: + str: Formatted response string. + """ if not free_prefixes: return self.translate('commands.prefix.no_free_prefixes') @@ -569,7 +594,15 @@ class PrefixCommand(BaseCommand): return response def format_prefix_response(self, prefix: str, data: Dict[str, Any]) -> str: - """Format the prefix response""" + """Format the prefix response. + + Args: + prefix: The prefix being queried. + data: The prefix data dictionary. + + Returns: + str: Formatted response string. + """ node_count = data['node_count'] node_names = data['node_names'] source = data.get('source', 'api') @@ -617,8 +650,13 @@ class PrefixCommand(BaseCommand): return response - async def _send_prefix_response(self, message: MeshMessage, response: str): - """Send prefix response, splitting into multiple messages if necessary""" + async def _send_prefix_response(self, message: MeshMessage, response: str) -> None: + """Send prefix response, splitting into multiple messages if necessary. + + Args: + message: The original message to respond to. + response: The complete response string. + """ # Store the complete response for web viewer integration BEFORE splitting # command_manager will prioritize command.last_response over _last_response # This ensures capture_command gets the full response, not just the last split message diff --git a/modules/commands/repeater_command.py b/modules/commands/repeater_command.py index 8bff489..9600f55 100644 --- a/modules/commands/repeater_command.py +++ b/modules/commands/repeater_command.py @@ -24,6 +24,7 @@ class RepeaterCommand(BaseCommand): requires_dm = True cooldown_seconds = 0 category = "management" + requires_internet = True # Requires internet access for geocoding (Nominatim) def __init__(self, bot): super().__init__(bot) diff --git a/modules/commands/satpass_command.py b/modules/commands/satpass_command.py index 999a675..5ea3086 100644 --- a/modules/commands/satpass_command.py +++ b/modules/commands/satpass_command.py @@ -16,6 +16,7 @@ class SatpassCommand(BaseCommand): keywords = ['satpass'] description = "Get satellite pass info: satpass [visual]" category = "solar" + requires_internet = True # Requires internet access for N2YO API # Common satellite shortcuts SATELLITE_SHORTCUTS = { diff --git a/modules/commands/solar_command.py b/modules/commands/solar_command.py index 5d2d3f9..01a7092 100644 --- a/modules/commands/solar_command.py +++ b/modules/commands/solar_command.py @@ -18,8 +18,9 @@ class SolarCommand(BaseCommand): # Plugin metadata name = "solar" keywords = ['solar'] - description = "Get solar conditions and HF band status" + description = "Get current solar conditions and HF band info" category = "solar" + requires_internet = True # Requires internet access for hamqsl.com API def __init__(self, bot): """Initialize the solar command. diff --git a/modules/commands/sports_command.py b/modules/commands/sports_command.py index ee9ce5f..34d348a 100644 --- a/modules/commands/sports_command.py +++ b/modules/commands/sports_command.py @@ -19,176 +19,15 @@ Team IDs should be periodically verified, especially after: - When users report "no games found" for known active teams """ -import re -import json -import requests -import time from datetime import datetime, timezone -from typing import List, Dict, Optional, Tuple +from typing import List, Dict, Optional from .base_command import BaseCommand from ..models import MeshMessage - - -class TheSportsDBClient: - """Client for TheSportsDB API with rate limiting - - Free tier: 30 requests per minute (1 request every 2 seconds) - """ - - BASE_URL = "https://www.thesportsdb.com/api/v1/json" - FREE_API_KEY = "123" # Free public API key - - def __init__(self, logger=None): - self.logger = logger - self.last_request_time = 0 - self.min_request_interval = 2.1 # Slightly more than 2 seconds for safety - - def _rate_limit(self): - """Enforce rate limiting""" - current_time = time.time() - time_since_last = current_time - self.last_request_time - if time_since_last < self.min_request_interval: - sleep_time = self.min_request_interval - time_since_last - time.sleep(sleep_time) - self.last_request_time = time.time() - - def search_team(self, team_name: str) -> Optional[Dict]: - """Search for a team by name""" - self._rate_limit() - url = f"{self.BASE_URL}/{self.FREE_API_KEY}/searchteams.php" - params = {'t': team_name} - - try: - response = requests.get(url, params=params, timeout=10) - response.raise_for_status() - data = response.json() - - teams = data.get('teams', []) - if teams: - return teams[0] # Return first match - return None - except Exception as e: - if self.logger: - self.logger.error(f"TheSportsDB search_team error: {e}") - return None - - def get_team_events_last(self, team_id: str, limit: int = 5) -> List[Dict]: - """Get last N events for a team""" - self._rate_limit() - url = f"{self.BASE_URL}/{self.FREE_API_KEY}/eventslast.php" - params = {'id': team_id} - - try: - response = requests.get(url, params=params, timeout=10) - response.raise_for_status() - data = response.json() - - events = data.get('results', []) - return events[:limit] - except Exception as e: - if self.logger: - self.logger.error(f"TheSportsDB get_team_events_last error: {e}") - return [] - - def get_team_events_next(self, team_id: str, limit: int = 5) -> List[Dict]: - """Get next N events for a team""" - self._rate_limit() - url = f"{self.BASE_URL}/{self.FREE_API_KEY}/eventsnext.php" - params = {'id': team_id} - - try: - response = requests.get(url, params=params, timeout=10) - response.raise_for_status() - data = response.json() - - events = data.get('events', []) - return events[:limit] - except Exception as e: - if self.logger: - self.logger.error(f"TheSportsDB get_team_events_next error: {e}") - return [] - - def get_league_teams(self, league_id: str) -> List[Dict]: - """Get all teams in a league""" - self._rate_limit() - url = f"{self.BASE_URL}/{self.FREE_API_KEY}/lookup_all_teams.php" - params = {'id': league_id} - - try: - response = requests.get(url, params=params, timeout=10) - response.raise_for_status() - data = response.json() - - teams = data.get('teams', []) - return teams - except Exception as e: - if self.logger: - self.logger.error(f"TheSportsDB get_league_teams error: {e}") - return [] - - def get_league_events_next(self, league_id: str, limit: int = 10) -> List[Dict]: - """Get next N events for a league""" - self._rate_limit() - url = f"{self.BASE_URL}/{self.FREE_API_KEY}/eventsnextleague.php" - params = {'id': league_id} - - try: - response = requests.get(url, params=params, timeout=10) - response.raise_for_status() - data = response.json() - - events = data.get('events', []) - return events[:limit] - except Exception as e: - if self.logger: - self.logger.error(f"TheSportsDB get_league_events_next error: {e}") - return [] - - def get_league_events_past(self, league_id: str, limit: int = 10) -> List[Dict]: - """Get past N events for a league""" - self._rate_limit() - url = f"{self.BASE_URL}/{self.FREE_API_KEY}/eventspastleague.php" - params = {'id': league_id} - - try: - response = requests.get(url, params=params, timeout=10) - response.raise_for_status() - data = response.json() - - events = data.get('results', []) # Note: past events use 'results' key - return events[:limit] - except Exception as e: - if self.logger: - self.logger.error(f"TheSportsDB get_league_events_past error: {e}") - return [] - - def get_events_by_day(self, date_str: str, league_id: str = None) -> List[Dict]: - """Get events for a specific day - - Args: - date_str: Date in YYYY-MM-DD format - league_id: Optional league ID to filter by - """ - self._rate_limit() - url = f"{self.BASE_URL}/{self.FREE_API_KEY}/eventsday.php" - params = {'d': date_str} - if league_id: - params['l'] = league_id - - try: - response = requests.get(url, params=params, timeout=10) - response.raise_for_status() - data = response.json() - - events = data.get('events', []) - # Handle case where API returns None instead of empty list - if events is None: - return [] - return events if isinstance(events, list) else [] - except Exception as e: - if self.logger: - self.logger.error(f"TheSportsDB get_events_by_day error: {e}") - return [] +from ..clients.espn_client import ESPNClient +from ..clients.thesportsdb_client import TheSportsDBClient +from ..clients.sports_mappings import ( + SPORT_EMOJIS, TEAM_MAPPINGS, LEAGUE_MAPPINGS +) class SportsCommand(BaseCommand): @@ -202,604 +41,19 @@ class SportsCommand(BaseCommand): cooldown_seconds = 3 # 3 second cooldown per user to prevent API abuse requires_internet = True # Requires internet access for ESPN API - # ESPN API base URL - ESPN_BASE_URL = "http://site.api.espn.com/apis/site/v2/sports" + # ESPN client + espn_client: Optional[ESPNClient] = None # TheSportsDB client for leagues not supported by ESPN thesportsdb_client: Optional[TheSportsDBClient] = None - # Sport emojis for easy identification - SPORT_EMOJIS = { - 'football': '🏈', - 'baseball': '⚾', - 'basketball': '🏀', - 'hockey': '🏒', - 'soccer': '⚽' - } - - # Custom team abbreviations to distinguish between leagues - # Only use -W suffixes for women's leagues - WOMENS_TEAM_ABBREVIATIONS = { - # NWSL teams - use custom abbreviations to distinguish from MLS - '21422': 'LA-W', # Angel City FC (Women's) - '22187': 'BAY-W', # Bay FC (Women's) - '15360': 'CHI-W', # Chicago Stars FC (Women's) - '15364': 'GFC-W', # Gotham FC (Women's) - '17346': 'HOU-W', # Houston Dash (Women's) - '20907': 'KC-W', # Kansas City Current (Women's) - '15366': 'NC-W', # North Carolina Courage (Women's) - '18206': 'ORL-W', # Orlando Pride (Women's) - '15362': 'POR-W', # Portland Thorns FC (Women's) - '20905': 'LOU-W', # Racing Louisville FC (Women's) - '21423': 'SD-W', # San Diego Wave FC (Women's) - '15363': 'SEA-W', # Seattle Reign FC (Women's) - '19141': 'UTA-W', # Utah Royals (Women's) - '15365': 'WAS-W', # Washington Spirit (Women's) - # WNBA teams - use custom abbreviations to distinguish from NBA - '14': 'SEA-W', # Seattle Storm (Women's) - '9': 'NY-W', # New York Liberty (Women's) - '6': 'LA-W', # Los Angeles Sparks (Women's) - '19': 'CHI-W', # Chicago Sky (Women's) - '20': 'ATL-W', # Atlanta Dream (Women's) - '18': 'CON-W', # Connecticut Sun (Women's) - '3': 'DAL-W', # Dallas Wings (Women's) - '129689': 'GS-W', # Golden State Valkyries (Women's) - '5': 'IND-W', # Indiana Fever (Women's) - '17': 'LV-W', # Las Vegas Aces (Women's) - '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 - # NOTE: Team IDs can change over time (see module docstring). - # Use test_scripts/find_espn_team_id.py to verify/update team IDs. - TEAM_MAPPINGS = { - # NFL Teams - 'seahawks': {'sport': 'football', 'league': 'nfl', 'team_id': '26'}, - 'hawks': {'sport': 'football', 'league': 'nfl', 'team_id': '26'}, - '49ers': {'sport': 'football', 'league': 'nfl', 'team_id': '25'}, - 'niners': {'sport': 'football', 'league': 'nfl', 'team_id': '25'}, - 'sf': {'sport': 'football', 'league': 'nfl', 'team_id': '25'}, - 'bears': {'sport': 'football', 'league': 'nfl', 'team_id': '3'}, - 'chicago': {'sport': 'football', 'league': 'nfl', 'team_id': '3'}, - 'chi': {'sport': 'football', 'league': 'nfl', 'team_id': '3'}, - 'bengals': {'sport': 'football', 'league': 'nfl', 'team_id': '4'}, - 'cincinnati': {'sport': 'football', 'league': 'nfl', 'team_id': '4'}, - 'cin': {'sport': 'football', 'league': 'nfl', 'team_id': '4'}, - 'bills': {'sport': 'football', 'league': 'nfl', 'team_id': '2'}, - 'buffalo': {'sport': 'football', 'league': 'nfl', 'team_id': '2'}, - 'buf': {'sport': 'football', 'league': 'nfl', 'team_id': '2'}, - 'broncos': {'sport': 'football', 'league': 'nfl', 'team_id': '7'}, - 'denver': {'sport': 'football', 'league': 'nfl', 'team_id': '7'}, - 'den': {'sport': 'football', 'league': 'nfl', 'team_id': '7'}, - 'browns': {'sport': 'football', 'league': 'nfl', 'team_id': '5'}, - 'cleveland': {'sport': 'football', 'league': 'nfl', 'team_id': '5'}, - 'cle': {'sport': 'football', 'league': 'nfl', 'team_id': '5'}, - 'buccaneers': {'sport': 'football', 'league': 'nfl', 'team_id': '27'}, - 'bucs': {'sport': 'football', 'league': 'nfl', 'team_id': '27'}, - 'tampa bay': {'sport': 'football', 'league': 'nfl', 'team_id': '27'}, - 'tb': {'sport': 'football', 'league': 'nfl', 'team_id': '27'}, - 'cardinals': {'sport': 'football', 'league': 'nfl', 'team_id': '22'}, - 'arizona': {'sport': 'football', 'league': 'nfl', 'team_id': '22'}, - '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'}, - 'colts': {'sport': 'football', 'league': 'nfl', 'team_id': '11'}, - 'indianapolis': {'sport': 'football', 'league': 'nfl', 'team_id': '11'}, - 'ind': {'sport': 'football', 'league': 'nfl', 'team_id': '11'}, - 'commanders': {'sport': 'football', 'league': 'nfl', 'team_id': '28'}, - 'washington': {'sport': 'football', 'league': 'nfl', 'team_id': '28'}, - 'wsh': {'sport': 'football', 'league': 'nfl', 'team_id': '28'}, - 'cowboys': {'sport': 'football', 'league': 'nfl', 'team_id': '6'}, - 'dallas': {'sport': 'football', 'league': 'nfl', 'team_id': '6'}, - 'dal': {'sport': 'football', 'league': 'nfl', 'team_id': '6'}, - 'dolphins': {'sport': 'football', 'league': 'nfl', 'team_id': '15'}, - 'miami': {'sport': 'football', 'league': 'nfl', 'team_id': '15'}, - 'mia': {'sport': 'football', 'league': 'nfl', 'team_id': '15'}, - 'eagles': {'sport': 'football', 'league': 'nfl', 'team_id': '21'}, - 'philadelphia': {'sport': 'football', 'league': 'nfl', 'team_id': '21'}, - 'phi': {'sport': 'football', 'league': 'nfl', 'team_id': '21'}, - 'falcons': {'sport': 'football', 'league': 'nfl', 'team_id': '1'}, - 'atlanta': {'sport': 'football', 'league': 'nfl', 'team_id': '1'}, - 'atl': {'sport': 'football', 'league': 'nfl', 'team_id': '1'}, - 'giants': {'sport': 'football', 'league': 'nfl', 'team_id': '19'}, - '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'}, - 'detroit': {'sport': 'football', 'league': 'nfl', 'team_id': '8'}, - 'det': {'sport': 'football', 'league': 'nfl', 'team_id': '8'}, - 'packers': {'sport': 'football', 'league': 'nfl', 'team_id': '9'}, - 'green bay': {'sport': 'football', 'league': 'nfl', 'team_id': '9'}, - 'gb': {'sport': 'football', 'league': 'nfl', 'team_id': '9'}, - 'panthers': {'sport': 'football', 'league': 'nfl', 'team_id': '29'}, - 'carolina': {'sport': 'football', 'league': 'nfl', 'team_id': '29'}, - 'car': {'sport': 'football', 'league': 'nfl', 'team_id': '29'}, - 'patriots': {'sport': 'football', 'league': 'nfl', 'team_id': '17'}, - 'new england': {'sport': 'football', 'league': 'nfl', 'team_id': '17'}, - 'ne': {'sport': 'football', 'league': 'nfl', 'team_id': '17'}, - 'raiders': {'sport': 'football', 'league': 'nfl', 'team_id': '13'}, - 'las vegas': {'sport': 'football', 'league': 'nfl', 'team_id': '13'}, - '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'}, - 'saints': {'sport': 'football', 'league': 'nfl', 'team_id': '18'}, - 'new orleans': {'sport': 'football', 'league': 'nfl', 'team_id': '18'}, - 'no': {'sport': 'football', 'league': 'nfl', 'team_id': '18'}, - 'steelers': {'sport': 'football', 'league': 'nfl', 'team_id': '23'}, - 'pittsburgh': {'sport': 'football', 'league': 'nfl', 'team_id': '23'}, - 'pit': {'sport': 'football', 'league': 'nfl', 'team_id': '23'}, - 'texans': {'sport': 'football', 'league': 'nfl', 'team_id': '34'}, - 'houston': {'sport': 'football', 'league': 'nfl', 'team_id': '34'}, - 'hou': {'sport': 'football', 'league': 'nfl', 'team_id': '34'}, - 'titans': {'sport': 'football', 'league': 'nfl', 'team_id': '10'}, - 'tennessee': {'sport': 'football', 'league': 'nfl', 'team_id': '10'}, - 'ten': {'sport': 'football', 'league': 'nfl', 'team_id': '10'}, - 'vikings': {'sport': 'football', 'league': 'nfl', 'team_id': '16'}, - 'minnesota': {'sport': 'football', 'league': 'nfl', 'team_id': '16'}, - 'min': {'sport': 'football', 'league': 'nfl', 'team_id': '16'}, - - # CFL Teams (Canadian Football League) - 'bc lions': {'sport': 'football', 'league': 'cfl', 'team_id': '79'}, - 'lions': {'sport': 'football', 'league': 'cfl', 'team_id': '79'}, - 'bcl': {'sport': 'football', 'league': 'cfl', 'team_id': '79'}, - 'calgary stampeders': {'sport': 'football', 'league': 'cfl', 'team_id': '80'}, - 'stampeders': {'sport': 'football', 'league': 'cfl', 'team_id': '80'}, - 'csp': {'sport': 'football', 'league': 'cfl', 'team_id': '80'}, - 'edmonton elks': {'sport': 'football', 'league': 'cfl', 'team_id': '81'}, - 'elks': {'sport': 'football', 'league': 'cfl', 'team_id': '81'}, - 'ees': {'sport': 'football', 'league': 'cfl', 'team_id': '81'}, - 'hamilton tiger-cats': {'sport': 'football', 'league': 'cfl', 'team_id': '82'}, - 'tiger-cats': {'sport': 'football', 'league': 'cfl', 'team_id': '82'}, - 'tigercats': {'sport': 'football', 'league': 'cfl', 'team_id': '82'}, - 'htc': {'sport': 'football', 'league': 'cfl', 'team_id': '82'}, - 'montreal alouettes': {'sport': 'football', 'league': 'cfl', 'team_id': '83'}, - 'alouettes': {'sport': 'football', 'league': 'cfl', 'team_id': '83'}, - 'mta': {'sport': 'football', 'league': 'cfl', 'team_id': '83'}, - 'ottawa redblacks': {'sport': 'football', 'league': 'cfl', 'team_id': '87'}, - 'redblacks': {'sport': 'football', 'league': 'cfl', 'team_id': '87'}, - 'red blacks': {'sport': 'football', 'league': 'cfl', 'team_id': '87'}, - 'orb': {'sport': 'football', 'league': 'cfl', 'team_id': '87'}, - 'saskatchewan roughriders': {'sport': 'football', 'league': 'cfl', 'team_id': '84'}, - 'roughriders': {'sport': 'football', 'league': 'cfl', 'team_id': '84'}, - 'riders': {'sport': 'football', 'league': 'cfl', 'team_id': '84'}, - 'srr': {'sport': 'football', 'league': 'cfl', 'team_id': '84'}, - 'toronto argonauts': {'sport': 'football', 'league': 'cfl', 'team_id': '85'}, - 'argonauts': {'sport': 'football', 'league': 'cfl', 'team_id': '85'}, - 'argos': {'sport': 'football', 'league': 'cfl', 'team_id': '85'}, - 'tat': {'sport': 'football', 'league': 'cfl', 'team_id': '85'}, - 'winnipeg blue bombers': {'sport': 'football', 'league': 'cfl', 'team_id': '86'}, - 'blue bombers': {'sport': 'football', 'league': 'cfl', 'team_id': '86'}, - 'bombers': {'sport': 'football', 'league': 'cfl', 'team_id': '86'}, - 'wbb': {'sport': 'football', 'league': 'cfl', 'team_id': '86'}, - - # MLB Teams - 'mariners': {'sport': 'baseball', 'league': 'mlb', 'team_id': '12'}, - 'seattle': {'sport': 'baseball', 'league': 'mlb', 'team_id': '12'}, - 'sea': {'sport': 'baseball', 'league': 'mlb', 'team_id': '12'}, - 'angels': {'sport': 'baseball', 'league': 'mlb', 'team_id': '3'}, - 'laa': {'sport': 'baseball', 'league': 'mlb', 'team_id': '3'}, - 'astros': {'sport': 'baseball', 'league': 'mlb', 'team_id': '18'}, - 'houston': {'sport': 'baseball', 'league': 'mlb', 'team_id': '18'}, - 'hou': {'sport': 'baseball', 'league': 'mlb', 'team_id': '18'}, - 'athletics': {'sport': 'baseball', 'league': 'mlb', 'team_id': '11'}, - 'a\'s': {'sport': 'baseball', 'league': 'mlb', 'team_id': '11'}, - 'oakland': {'sport': 'baseball', 'league': 'mlb', 'team_id': '11'}, - 'oak': {'sport': 'baseball', 'league': 'mlb', 'team_id': '11'}, - 'blue jays': {'sport': 'baseball', 'league': 'mlb', 'team_id': '14'}, - 'toronto': {'sport': 'baseball', 'league': 'mlb', 'team_id': '14'}, - 'tor': {'sport': 'baseball', 'league': 'mlb', 'team_id': '14'}, - 'braves': {'sport': 'baseball', 'league': 'mlb', 'team_id': '15'}, - 'atlanta': {'sport': 'baseball', 'league': 'mlb', 'team_id': '15'}, - 'atl': {'sport': 'baseball', 'league': 'mlb', 'team_id': '15'}, - 'brewers': {'sport': 'baseball', 'league': 'mlb', 'team_id': '8'}, - 'milwaukee': {'sport': 'baseball', 'league': 'mlb', 'team_id': '8'}, - 'mil': {'sport': 'baseball', 'league': 'mlb', 'team_id': '8'}, - 'cardinals': {'sport': 'baseball', 'league': 'mlb', 'team_id': '24'}, - 'st louis': {'sport': 'baseball', 'league': 'mlb', 'team_id': '24'}, - 'stl': {'sport': 'baseball', 'league': 'mlb', 'team_id': '24'}, - 'cubs': {'sport': 'baseball', 'league': 'mlb', 'team_id': '16'}, - 'chicago': {'sport': 'baseball', 'league': 'mlb', 'team_id': '16'}, - 'chc': {'sport': 'baseball', 'league': 'mlb', 'team_id': '16'}, - 'diamondbacks': {'sport': 'baseball', 'league': 'mlb', 'team_id': '29'}, - 'arizona': {'sport': 'baseball', 'league': 'mlb', 'team_id': '29'}, - 'ari': {'sport': 'baseball', 'league': 'mlb', 'team_id': '29'}, - 'dodgers': {'sport': 'baseball', 'league': 'mlb', 'team_id': '19'}, - 'lad': {'sport': 'baseball', 'league': 'mlb', 'team_id': '19'}, - 'giants': {'sport': 'baseball', 'league': 'mlb', 'team_id': '26'}, - 'san francisco': {'sport': 'baseball', 'league': 'mlb', 'team_id': '26'}, - 'sf': {'sport': 'baseball', 'league': 'mlb', 'team_id': '26'}, - 'guardians': {'sport': 'baseball', 'league': 'mlb', 'team_id': '5'}, - 'cleveland': {'sport': 'baseball', 'league': 'mlb', 'team_id': '5'}, - 'cle': {'sport': 'baseball', 'league': 'mlb', 'team_id': '5'}, - 'marlins': {'sport': 'baseball', 'league': 'mlb', 'team_id': '28'}, - 'miami': {'sport': 'baseball', 'league': 'mlb', 'team_id': '28'}, - 'mia': {'sport': 'baseball', 'league': 'mlb', 'team_id': '28'}, - 'mets': {'sport': 'baseball', 'league': 'mlb', 'team_id': '21'}, - 'nym': {'sport': 'baseball', 'league': 'mlb', 'team_id': '21'}, - 'nationals': {'sport': 'baseball', 'league': 'mlb', 'team_id': '20'}, - 'washington': {'sport': 'baseball', 'league': 'mlb', 'team_id': '20'}, - 'was': {'sport': 'baseball', 'league': 'mlb', 'team_id': '20'}, - 'orioles': {'sport': 'baseball', 'league': 'mlb', 'team_id': '1'}, - 'baltimore': {'sport': 'baseball', 'league': 'mlb', 'team_id': '1'}, - 'bal': {'sport': 'baseball', 'league': 'mlb', 'team_id': '1'}, - 'padres': {'sport': 'baseball', 'league': 'mlb', 'team_id': '25'}, - 'san diego': {'sport': 'baseball', 'league': 'mlb', 'team_id': '25'}, - 'sd': {'sport': 'baseball', 'league': 'mlb', 'team_id': '25'}, - 'phillies': {'sport': 'baseball', 'league': 'mlb', 'team_id': '22'}, - 'philadelphia': {'sport': 'baseball', 'league': 'mlb', 'team_id': '22'}, - 'phi': {'sport': 'baseball', 'league': 'mlb', 'team_id': '22'}, - 'pirates': {'sport': 'baseball', 'league': 'mlb', 'team_id': '23'}, - 'pittsburgh': {'sport': 'baseball', 'league': 'mlb', 'team_id': '23'}, - 'pit': {'sport': 'baseball', 'league': 'mlb', 'team_id': '23'}, - 'rangers': {'sport': 'baseball', 'league': 'mlb', 'team_id': '13'}, - 'texas': {'sport': 'baseball', 'league': 'mlb', 'team_id': '13'}, - 'tex': {'sport': 'baseball', 'league': 'mlb', 'team_id': '13'}, - 'rays': {'sport': 'baseball', 'league': 'mlb', 'team_id': '30'}, - 'tampa bay': {'sport': 'baseball', 'league': 'mlb', 'team_id': '30'}, - 'tb': {'sport': 'baseball', 'league': 'mlb', 'team_id': '30'}, - 'red sox': {'sport': 'baseball', 'league': 'mlb', 'team_id': '2'}, - 'boston': {'sport': 'baseball', 'league': 'mlb', 'team_id': '2'}, - 'bos': {'sport': 'baseball', 'league': 'mlb', 'team_id': '2'}, - 'reds': {'sport': 'baseball', 'league': 'mlb', 'team_id': '17'}, - 'cincinnati': {'sport': 'baseball', 'league': 'mlb', 'team_id': '17'}, - 'cin': {'sport': 'baseball', 'league': 'mlb', 'team_id': '17'}, - 'rockies': {'sport': 'baseball', 'league': 'mlb', 'team_id': '27'}, - 'colorado': {'sport': 'baseball', 'league': 'mlb', 'team_id': '27'}, - 'col': {'sport': 'baseball', 'league': 'mlb', 'team_id': '27'}, - 'royals': {'sport': 'baseball', 'league': 'mlb', 'team_id': '7'}, - 'kansas city': {'sport': 'baseball', 'league': 'mlb', 'team_id': '7'}, - 'kc': {'sport': 'baseball', 'league': 'mlb', 'team_id': '7'}, - 'tigers': {'sport': 'baseball', 'league': 'mlb', 'team_id': '6'}, - 'detroit': {'sport': 'baseball', 'league': 'mlb', 'team_id': '6'}, - 'det': {'sport': 'baseball', 'league': 'mlb', 'team_id': '6'}, - 'twins': {'sport': 'baseball', 'league': 'mlb', 'team_id': '9'}, - 'minnesota': {'sport': 'baseball', 'league': 'mlb', 'team_id': '9'}, - 'min': {'sport': 'baseball', 'league': 'mlb', 'team_id': '9'}, - 'white sox': {'sport': 'baseball', 'league': 'mlb', 'team_id': '4'}, - 'chw': {'sport': 'baseball', 'league': 'mlb', 'team_id': '4'}, - 'yankees': {'sport': 'baseball', 'league': 'mlb', 'team_id': '10'}, - 'new york': {'sport': 'baseball', 'league': 'mlb', 'team_id': '10'}, - 'nyy': {'sport': 'baseball', 'league': 'mlb', 'team_id': '10'}, - - # NBA Teams (limited data available from API) - 'lakers': {'sport': 'basketball', 'league': 'nba', 'team_id': '13'}, - 'warriors': {'sport': 'basketball', 'league': 'nba', 'team_id': '9'}, - 'celtics': {'sport': 'basketball', 'league': 'nba', 'team_id': '2'}, - 'heat': {'sport': 'basketball', 'league': 'nba', 'team_id': '14'}, - '76ers': {'sport': 'basketball', 'league': 'nba', 'team_id': '20'}, - 'knicks': {'sport': 'basketball', 'league': 'nba', 'team_id': '18'}, - 'pelicans': {'sport': 'basketball', 'league': 'nba', 'team_id': '3'}, - 'trail blazers': {'sport': 'basketball', 'league': 'nba', 'team_id': '22'}, - 'blazers': {'sport': 'basketball', 'league': 'nba', 'team_id': '22'}, - - # WNBA Teams - 'storm': {'sport': 'basketball', 'league': 'wnba', 'team_id': '14'}, - 'seattle storm': {'sport': 'basketball', 'league': 'wnba', 'team_id': '14'}, - 'liberty': {'sport': 'basketball', 'league': 'wnba', 'team_id': '9'}, - 'new york liberty': {'sport': 'basketball', 'league': 'wnba', 'team_id': '9'}, - 'sparks': {'sport': 'basketball', 'league': 'wnba', 'team_id': '6'}, - 'los angeles sparks': {'sport': 'basketball', 'league': 'wnba', 'team_id': '6'}, - 'sky': {'sport': 'basketball', 'league': 'wnba', 'team_id': '19'}, - 'chicago sky': {'sport': 'basketball', 'league': 'wnba', 'team_id': '19'}, - 'dream': {'sport': 'basketball', 'league': 'wnba', 'team_id': '20'}, - 'atlanta dream': {'sport': 'basketball', 'league': 'wnba', 'team_id': '20'}, - 'sun': {'sport': 'basketball', 'league': 'wnba', 'team_id': '18'}, - 'connecticut sun': {'sport': 'basketball', 'league': 'wnba', 'team_id': '18'}, - 'wings': {'sport': 'basketball', 'league': 'wnba', 'team_id': '3'}, - 'dallas wings': {'sport': 'basketball', 'league': 'wnba', 'team_id': '3'}, - 'valkyries': {'sport': 'basketball', 'league': 'wnba', 'team_id': '129689'}, - 'golden state valkyries': {'sport': 'basketball', 'league': 'wnba', 'team_id': '129689'}, - 'fever': {'sport': 'basketball', 'league': 'wnba', 'team_id': '5'}, - 'indiana fever': {'sport': 'basketball', 'league': 'wnba', 'team_id': '5'}, - 'aces': {'sport': 'basketball', 'league': 'wnba', 'team_id': '17'}, - 'las vegas aces': {'sport': 'basketball', 'league': 'wnba', 'team_id': '17'}, - 'lynx': {'sport': 'basketball', 'league': 'wnba', 'team_id': '8'}, - 'minnesota lynx': {'sport': 'basketball', 'league': 'wnba', 'team_id': '8'}, - 'mercury': {'sport': 'basketball', 'league': 'wnba', 'team_id': '11'}, - 'phoenix mercury': {'sport': 'basketball', 'league': 'wnba', 'team_id': '11'}, - 'mystics': {'sport': 'basketball', 'league': 'wnba', 'team_id': '16'}, - 'washington mystics': {'sport': 'basketball', 'league': 'wnba', 'team_id': '16'}, - - # 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'}, - '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'}, - - # WHL Teams (Western Hockey League) - using TheSportsDB API - 'thunderbirds': {'sport': 'hockey', 'league': 'whl', 'team_id': '144380', 'api_source': 'thesportsdb'}, - 'seattle thunderbirds': {'sport': 'hockey', 'league': 'whl', 'team_id': '144380', 'api_source': 'thesportsdb'}, - 't-birds': {'sport': 'hockey', 'league': 'whl', 'team_id': '144380', 'api_source': 'thesportsdb'}, - 'winterhawks': {'sport': 'hockey', 'league': 'whl', 'team_id': '144379', 'api_source': 'thesportsdb'}, - 'portland winterhawks': {'sport': 'hockey', 'league': 'whl', 'team_id': '144379', 'api_source': 'thesportsdb'}, - 'silvertips': {'sport': 'hockey', 'league': 'whl', 'team_id': '144378', 'api_source': 'thesportsdb'}, - 'everett silvertips': {'sport': 'hockey', 'league': 'whl', 'team_id': '144378', 'api_source': 'thesportsdb'}, - 'everett': {'sport': 'hockey', 'league': 'whl', 'team_id': '144378', 'api_source': 'thesportsdb'}, - 'spokane chiefs': {'sport': 'hockey', 'league': 'whl', 'team_id': '144381', 'api_source': 'thesportsdb'}, - 'spokane': {'sport': 'hockey', 'league': 'whl', 'team_id': '144381', 'api_source': 'thesportsdb'}, - 'vancouver giants': {'sport': 'hockey', 'league': 'whl', 'team_id': '144376', 'api_source': 'thesportsdb'}, - 'blazers': {'sport': 'hockey', 'league': 'whl', 'team_id': '144373', 'api_source': 'thesportsdb'}, - 'kamloops blazers': {'sport': 'hockey', 'league': 'whl', 'team_id': '144373', 'api_source': 'thesportsdb'}, - 'kamloops': {'sport': 'hockey', 'league': 'whl', 'team_id': '144373', 'api_source': 'thesportsdb'}, - 'cougars': {'sport': 'hockey', 'league': 'whl', 'team_id': '144375', 'api_source': 'thesportsdb'}, - 'prince george cougars': {'sport': 'hockey', 'league': 'whl', 'team_id': '144375', 'api_source': 'thesportsdb'}, - 'prince george': {'sport': 'hockey', 'league': 'whl', 'team_id': '144375', 'api_source': 'thesportsdb'}, - 'rockets': {'sport': 'hockey', 'league': 'whl', 'team_id': '144374', 'api_source': 'thesportsdb'}, - 'kelowna rockets': {'sport': 'hockey', 'league': 'whl', 'team_id': '144374', 'api_source': 'thesportsdb'}, - 'kelowna': {'sport': 'hockey', 'league': 'whl', 'team_id': '144374', 'api_source': 'thesportsdb'}, - 'tri-city americans': {'sport': 'hockey', 'league': 'whl', 'team_id': '144382', 'api_source': 'thesportsdb'}, - 'americans': {'sport': 'hockey', 'league': 'whl', 'team_id': '144382', 'api_source': 'thesportsdb'}, - 'tri city': {'sport': 'hockey', 'league': 'whl', 'team_id': '144382', 'api_source': 'thesportsdb'}, - 'tricity': {'sport': 'hockey', 'league': 'whl', 'team_id': '144382', 'api_source': 'thesportsdb'}, - 'wenatchee wild': {'sport': 'hockey', 'league': 'whl', 'team_id': '144372', 'api_source': 'thesportsdb'}, - 'wenatchee': {'sport': 'hockey', 'league': 'whl', 'team_id': '144372', 'api_source': 'thesportsdb'}, - 'victoria royals': {'sport': 'hockey', 'league': 'whl', 'team_id': '144377', 'api_source': 'thesportsdb'}, - 'victoria': {'sport': 'hockey', 'league': 'whl', 'team_id': '144377', 'api_source': 'thesportsdb'}, - 'edmonton oil kings': {'sport': 'hockey', 'league': 'whl', 'team_id': '144362', 'api_source': 'thesportsdb'}, - 'oil kings': {'sport': 'hockey', 'league': 'whl', 'team_id': '144362', 'api_source': 'thesportsdb'}, - 'calgary hitmen': {'sport': 'hockey', 'league': 'whl', 'team_id': '144361', 'api_source': 'thesportsdb'}, - 'hitmen': {'sport': 'hockey', 'league': 'whl', 'team_id': '144361', 'api_source': 'thesportsdb'}, - 'red deer rebels': {'sport': 'hockey', 'league': 'whl', 'team_id': '144365', 'api_source': 'thesportsdb'}, - 'red deer': {'sport': 'hockey', 'league': 'whl', 'team_id': '144365', 'api_source': 'thesportsdb'}, - 'medicine hat tigers': {'sport': 'hockey', 'league': 'whl', 'team_id': '144364', 'api_source': 'thesportsdb'}, - 'medicine hat': {'sport': 'hockey', 'league': 'whl', 'team_id': '144364', 'api_source': 'thesportsdb'}, - 'lethbridge hurricanes': {'sport': 'hockey', 'league': 'whl', 'team_id': '144363', 'api_source': 'thesportsdb'}, - 'lethbridge': {'sport': 'hockey', 'league': 'whl', 'team_id': '144363', 'api_source': 'thesportsdb'}, - 'swift current broncos': {'sport': 'hockey', 'league': 'whl', 'team_id': '144366', 'api_source': 'thesportsdb'}, - 'swift current': {'sport': 'hockey', 'league': 'whl', 'team_id': '144366', 'api_source': 'thesportsdb'}, - 'moose jaw warriors': {'sport': 'hockey', 'league': 'whl', 'team_id': '144368', 'api_source': 'thesportsdb'}, - 'moose jaw': {'sport': 'hockey', 'league': 'whl', 'team_id': '144368', 'api_source': 'thesportsdb'}, - 'regina pats': {'sport': 'hockey', 'league': 'whl', 'team_id': '144370', 'api_source': 'thesportsdb'}, - 'pats': {'sport': 'hockey', 'league': 'whl', 'team_id': '144370', 'api_source': 'thesportsdb'}, - 'regina': {'sport': 'hockey', 'league': 'whl', 'team_id': '144370', 'api_source': 'thesportsdb'}, - 'saskatoon blades': {'sport': 'hockey', 'league': 'whl', 'team_id': '144371', 'api_source': 'thesportsdb'}, - 'blades': {'sport': 'hockey', 'league': 'whl', 'team_id': '144371', 'api_source': 'thesportsdb'}, - 'saskatoon': {'sport': 'hockey', 'league': 'whl', 'team_id': '144371', 'api_source': 'thesportsdb'}, - 'prince albert raiders': {'sport': 'hockey', 'league': 'whl', 'team_id': '144369', 'api_source': 'thesportsdb'}, - 'prince albert': {'sport': 'hockey', 'league': 'whl', 'team_id': '144369', 'api_source': 'thesportsdb'}, - 'brandon wheat kings': {'sport': 'hockey', 'league': 'whl', 'team_id': '144367', 'api_source': 'thesportsdb'}, - 'wheat kings': {'sport': 'hockey', 'league': 'whl', 'team_id': '144367', 'api_source': 'thesportsdb'}, - 'brandon': {'sport': 'hockey', 'league': 'whl', 'team_id': '144367', 'api_source': 'thesportsdb'}, - - # MLS Teams - 'sounders': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9726'}, - 'seattle sounders': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9726'}, - - # NWSL Teams - 'reign': {'sport': 'soccer', 'league': 'usa.nwsl', 'team_id': '15363'}, - 'seattle reign': {'sport': 'soccer', 'league': 'usa.nwsl', 'team_id': '15363'}, - 'racing': {'sport': 'soccer', 'league': 'usa.nwsl', 'team_id': '20905'}, - 'racing louisville': {'sport': 'soccer', 'league': 'usa.nwsl', 'team_id': '20905'}, - 'louisville': {'sport': 'soccer', 'league': 'usa.nwsl', 'team_id': '20905'}, - 'atlanta united': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18418'}, - 'atl': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18418'}, - 'austin fc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '20906'}, - 'atx': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '20906'}, - 'cf montreal': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9720'}, - 'montreal': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9720'}, - 'mtl': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9720'}, - 'charlotte fc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '21300'}, - 'clt': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '21300'}, - 'chicago fire': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '182'}, - 'fire': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '182'}, - 'chi': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '182'}, - 'rapids': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '184'}, - 'colorado': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '184'}, - 'col': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '184'}, - 'crew': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '183'}, - 'columbus': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '183'}, - 'clb': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '183'}, - 'dc united': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '193'}, - 'dc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '193'}, - 'fc cincinnati': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18267'}, - 'cincinnati': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18267'}, - 'cin': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18267'}, - 'fc dallas': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '185'}, - 'dallas': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '185'}, - 'dal': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '185'}, - 'dynamo': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '6077'}, - 'houston': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '6077'}, - 'hou': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '6077'}, - 'inter miami': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '20232'}, - 'miami': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '20232'}, - 'mia': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '20232'}, - 'la galaxy': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '187'}, - 'galaxy': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '187'}, - 'la': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '187'}, - 'lafc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18966'}, - 'minnesota united': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '17362'}, - 'minnesota': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '17362'}, - 'min': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '17362'}, - 'nashville sc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18986'}, - 'nashville': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18986'}, - 'nsh': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '18986'}, - 'revolution': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '189'}, - 'new england': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '189'}, - 'ne': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '189'}, - 'nyc fc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '17606'}, - 'nyc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '17606'}, - 'red bulls': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '190'}, - 'ny': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '190'}, - 'orlando city': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '12011'}, - 'orlando': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '12011'}, - 'orl': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '12011'}, - 'union': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '10739'}, - 'philadelphia': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '10739'}, - 'phi': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '10739'}, - 'timbers': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9723'}, - 'portland': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9723'}, - 'por': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9723'}, - 'real salt lake': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '4771'}, - 'salt lake': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '4771'}, - 'rsl': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '4771'}, - 'san diego fc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '22529'}, - 'san diego': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '22529'}, - 'sd': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '22529'}, - 'earthquakes': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '191'}, - 'san jose': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '191'}, - 'sj': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '191'}, - 'sporting kc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '186'}, - 'sporting kansas city': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '186'}, - 'skc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '186'}, - 'st louis city': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '21812'}, - 'st louis': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '21812'}, - 'stl': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '21812'}, - 'toronto fc': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '7318'}, - 'toronto': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '7318'}, - 'tor': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '7318'}, - 'whitecaps': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9727'}, - 'vancouver': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9727'}, - 'van': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9727'}, - - # Premier League Teams - 'lfc': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '364'}, - 'liverpool': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '364'}, - 'manchester united': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '360'}, - 'man united': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '360'}, - 'arsenal': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '359'}, - 'chelsea': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '363'}, - 'manchester city': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '382'}, - 'man city': {'sport': 'soccer', 'league': 'eng.1', 'team_id': '382'}, - } def __init__(self, bot): super().__init__(bot) self.url_timeout = 10 # seconds - # Initialize TheSportsDB client + # Initialize API clients + self.espn_client = ESPNClient(logger=self.logger, timeout=self.url_timeout) self.thesportsdb_client = TheSportsDBClient(logger=self.logger) # Load default teams from config @@ -830,92 +84,7 @@ class SportsCommand(BaseCommand): overrides[channel.strip()] = team.strip().lower() return overrides - def is_womens_league(self, sport: str, league: str) -> bool: - """Check if the league is a women's league""" - womens_leagues = { - ('basketball', 'wnba'), - ('soccer', 'usa.nwsl'), - ('hockey', 'pwhl') - } - return (sport, league) in womens_leagues - def get_team_abbreviation(self, team_id: str, team_abbreviation: str, sport: str, league: str) -> str: - """Get team abbreviation, using -W suffix only for women's leagues""" - if self.is_womens_league(sport, league): - return self.WOMENS_TEAM_ABBREVIATIONS.get(team_id, team_abbreviation) - else: - return team_abbreviation - - def extract_score(self, competitor: Dict) -> str: - """Extract score value from competitor data, handling both dict and string formats - - ESPN API returns scores in different formats: - - Schedule endpoint: {'value': 13.0, 'displayValue': '13'} - - Scoreboard endpoint: may be string or dict format - - Returns the score as a string for consistent formatting. - """ - score = competitor.get('score', '0') - - # Handle dictionary format (from schedule endpoint) - if isinstance(score, dict): - # Prefer displayValue if available, otherwise use value - if 'displayValue' in score: - return str(score['displayValue']) - elif 'value' in score: - # Convert float to int if it's a whole number, otherwise keep as is - value = score['value'] - if isinstance(value, float) and value.is_integer(): - return str(int(value)) - return str(value) - else: - return '0' - - # Handle string format (from scoreboard endpoint or already processed) - if isinstance(score, str): - return score - - # Handle numeric format - if isinstance(score, (int, float)): - if isinstance(score, float) and score.is_integer(): - return str(int(score)) - return str(score) - - # Fallback - return '0' - - def extract_shootout_score(self, competitor: Dict) -> Optional[int]: - """Extract penalty shootout score from competitor data""" - score = competitor.get('score', {}) - if isinstance(score, dict) and 'shootoutScore' in score: - shootout = score['shootoutScore'] - if isinstance(shootout, (int, float)): - return int(shootout) if isinstance(shootout, float) and shootout.is_integer() else int(shootout) - return None - - def format_clean_date_time(self, dt) -> str: - """Format date and time without leading zeros""" - month = dt.month - day = dt.day - minute = dt.minute - ampm = dt.strftime("%p") - - # Convert to 12-hour format - hour_12 = dt.hour - if hour_12 == 0: - hour_12 = 12 - elif hour_12 > 12: - hour_12 = hour_12 - 12 - - # Remove leading zeros - time_str = f"{month}/{day} {hour_12}:{minute:02d} {ampm}" - return time_str - - def format_clean_date(self, dt) -> str: - """Format date without leading zeros""" - month = dt.month - day = dt.day - return f"{month}/{day}" def matches_keyword(self, message: MeshMessage) -> bool: """Check if this command matches the message content - sports must be first word""" @@ -954,37 +123,6 @@ class SportsCommand(BaseCommand): def get_help_text(self) -> str: return self.translate('commands.sports.help') - async def execute(self, message: MeshMessage) -> bool: - """Execute the sports command""" - try: - # Record execution for this user (handles cooldown) - self.record_execution(message.sender_id) - - # Parse the command - content = message.content.strip() - if content.startswith('!'): - content = content[1:].strip() - - # 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: - # Check if this channel has an override team - if not message.is_dm and message.channel in self.channel_overrides: - override_team = self.channel_overrides[message.channel] - response = await self.get_team_scores(override_team) - else: - response = await self.get_default_teams_scores() - - # Send response - return await self.send_response(message, response) - - except Exception as e: - self.logger.error(f"Error in sports command: {e}") - return await self.send_response(message, self.translate('commands.sports.error_fetching')) async def get_default_teams_scores(self) -> str: """Get scores for default teams, sorted by game time""" @@ -994,7 +132,7 @@ class SportsCommand(BaseCommand): game_data = [] for team in self.default_teams: try: - team_info = self.TEAM_MAPPINGS.get(team) + team_info = TEAM_MAPPINGS.get(team) if team_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) @@ -1012,7 +150,7 @@ class SportsCommand(BaseCommand): # Format responses with sport emojis responses = [] for game in game_data: - sport_emoji = self.SPORT_EMOJIS.get(game['sport'], '🏆') + sport_emoji = SPORT_EMOJIS.get(game['sport'], '🏆') responses.append(f"{sport_emoji} {game['formatted']}") # Join responses with newlines and ensure under 130 characters @@ -1029,56 +167,7 @@ class SportsCommand(BaseCommand): def get_league_info(self, league_name: str) -> Optional[Dict[str, str]]: """Get league information for league queries""" - league_mappings = { - # NFL - 'nfl': {'sport': 'football', 'league': 'nfl'}, - 'football': {'sport': 'football', 'league': 'nfl'}, - - # CFL - 'cfl': {'sport': 'football', 'league': 'cfl'}, - 'canadian football': {'sport': 'football', 'league': 'cfl'}, - - # MLB - 'mlb': {'sport': 'baseball', 'league': 'mlb'}, - 'baseball': {'sport': 'baseball', 'league': 'mlb'}, - - # NBA - 'nba': {'sport': 'basketball', 'league': 'nba'}, - 'basketball': {'sport': 'basketball', 'league': 'nba'}, - - # WNBA - 'wnba': {'sport': 'basketball', 'league': 'wnba'}, - 'womens basketball': {'sport': 'basketball', 'league': 'wnba'}, - 'womens': {'sport': 'basketball', 'league': 'wnba'}, - - # NHL - 'nhl': {'sport': 'hockey', 'league': 'nhl'}, - 'hockey': {'sport': 'hockey', 'league': 'nhl'}, - - # PWHL - 'pwhl': {'sport': 'hockey', 'league': 'pwhl'}, - 'womens hockey': {'sport': 'hockey', 'league': 'pwhl'}, - - # WHL (Western Hockey League) - using TheSportsDB - 'whl': {'sport': 'hockey', 'league': 'whl', 'api_source': 'thesportsdb', 'league_id': '5160'}, - 'western hockey league': {'sport': 'hockey', 'league': 'whl', 'api_source': 'thesportsdb', 'league_id': '5160'}, - - # MLS - 'mls': {'sport': 'soccer', 'league': 'usa.1'}, - 'soccer': {'sport': 'soccer', 'league': 'usa.1'}, - - # NWSL - 'nwsl': {'sport': 'soccer', 'league': 'usa.nwsl'}, - 'womens soccer': {'sport': 'soccer', 'league': 'usa.nwsl'}, - 'womens': {'sport': 'soccer', 'league': 'usa.nwsl'}, - - # Premier League - 'epl': {'sport': 'soccer', 'league': 'eng.1'}, - 'premier league': {'sport': 'soccer', 'league': 'eng.1'}, - 'premier': {'sport': 'soccer', 'league': 'eng.1'}, - } - - return league_mappings.get(league_name.lower()) + return LEAGUE_MAPPINGS.get(league_name.lower()) def get_city_teams(self, city_name: str) -> List[Dict[str, str]]: """Get all teams for a given city""" @@ -1095,7 +184,6 @@ class SportsCommand(BaseCommand): 'miami': ['dolphins', 'marlins', 'heat', 'inter miami'], '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'], 'houston': ['texans', 'astros', 'dynamo'], 'dallas': ['cowboys', 'rangers', 'stars', 'fc dallas', 'wings'], @@ -1178,7 +266,7 @@ class SportsCommand(BaseCommand): # Get team info for each team name city_teams = [] for team_name in team_names: - team_info = self.TEAM_MAPPINGS.get(team_name) + team_info = TEAM_MAPPINGS.get(team_name) if team_info: city_teams.append(team_info) @@ -1208,7 +296,7 @@ class SportsCommand(BaseCommand): # Format responses with sport emojis responses = [] for game in game_data: - sport_emoji = self.SPORT_EMOJIS.get(game['sport'], '🏆') + sport_emoji = SPORT_EMOJIS.get(game['sport'], '🏆') responses.append(f"{sport_emoji} {game['formatted']}") # Join responses with newlines and ensure under 130 characters @@ -1231,25 +319,10 @@ class SportsCommand(BaseCommand): # Default to ESPN API try: - # Construct API URL - url = f"{self.ESPN_BASE_URL}/{league_info['sport']}/{league_info['league']}/scoreboard" - - # 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 self.translate('commands.sports.no_games_league', sport=league_info['sport']) - - # Parse all games and sort by time - game_data = [] - for event in events: - game_info = self.parse_league_game_event(event, league_info['sport'], league_info['league']) - if game_info: - game_data.append(game_info) + # Fetch and parse scoreboard via client + game_data = await self.espn_client.fetch_scoreboard( + league_info['sport'], league_info['league'] + ) if not game_data: return self.translate('commands.sports.no_games_league', sport=league_info['sport']) @@ -1260,7 +333,7 @@ class SportsCommand(BaseCommand): # Format responses with sport emojis responses = [] for game in game_data[:5]: # Limit to 5 games to keep under 130 chars - sport_emoji = self.SPORT_EMOJIS.get(game['sport'], '🏆') + sport_emoji = SPORT_EMOJIS.get(game['sport'], '🏆') responses.append(f"{sport_emoji} {game['formatted']}") # Join responses with newlines and ensure under 130 characters @@ -1280,10 +353,7 @@ class SportsCommand(BaseCommand): return self.translate('commands.sports.error_fetching_league', sport=league_info['sport']) async def get_league_scores_thesportsdb(self, league_info: Dict[str, str]) -> str: - """Get upcoming games for a league from TheSportsDB - - Fetches both upcoming and recent past events to provide a fuller response. - """ + """Get upcoming games for a league from TheSportsDB""" if not self.thesportsdb_client: self.logger.error("TheSportsDB client not initialized") return self.translate('commands.sports.error_fetching_league', sport=league_info.get('sport', 'unknown')) @@ -1294,115 +364,25 @@ class SportsCommand(BaseCommand): return f"League ID not configured for {league_name}. Please query specific teams instead." try: - # Fetch events from multiple sources to get more results - import asyncio - from datetime import timedelta - loop = asyncio.get_event_loop() - - # Get today's date and next few days - today = datetime.now().date() - date_strings = [today.strftime('%Y-%m-%d')] - for i in range(1, 7): # Next 6 days - date_strings.append((today + timedelta(days=i)).strftime('%Y-%m-%d')) - - # Fetch events from multiple sources in parallel - next_events_task = loop.run_in_executor( - None, - lambda: self.thesportsdb_client.get_league_events_next(league_id, limit=15) - ) - past_events_task = loop.run_in_executor( - None, - lambda: self.thesportsdb_client.get_league_events_past(league_id, limit=5) + # Delegate to client + all_event_data = await self.thesportsdb_client.fetch_league_scores( + league_info['sport'], league_info['league'], league_id ) - # Fetch events for today and next few days - def make_day_fetcher(date_str): - return lambda: self.thesportsdb_client.get_events_by_day(date_str, league_id) + if not all_event_data: + return self.translate('commands.sports.no_recent_scores', sport=league_info['sport']) - day_events_tasks = [ - loop.run_in_executor(None, make_day_fetcher(d)) - for d in date_strings - ] + # Sort by timestamp + all_event_data.sort(key=lambda x: x['timestamp']) - # Wait for all requests - results = await asyncio.gather(next_events_task, past_events_task, *day_events_tasks) - next_events = results[0] - past_events = results[1] - day_events_list = results[2:] # List of lists from each day - - # Combine all day events into a single list - all_day_events = [] - for day_events in day_events_list: - all_day_events.extend(day_events) - - # Parse events - combine all sources and deduplicate by event ID - now = datetime.now(timezone.utc).timestamp() - eight_days_ago = now - (8 * 24 * 60 * 60) - six_weeks_from_now = now + (6 * 7 * 24 * 60 * 60) - - # Combine all events and deduplicate by event ID - all_events = [] - seen_event_ids = set() - - # Add past events - for event in past_events: - event_id = str(event.get('idEvent', '')) - if event_id and event_id not in seen_event_ids: - all_events.append(event) - seen_event_ids.add(event_id) - - # Add next events - for event in next_events: - event_id = str(event.get('idEvent', '')) - if event_id and event_id not in seen_event_ids: - all_events.append(event) - seen_event_ids.add(event_id) - - # Add day events - for event in all_day_events: - event_id = str(event.get('idEvent', '')) - if event_id and event_id not in seen_event_ids: - all_events.append(event) - seen_event_ids.add(event_id) - - # Parse all events - game_data = [] - for event in all_events: - game_info = self.parse_thesportsdb_league_event(event, league_info['sport'], league_info['league']) - if game_info: - event_ts = game_info.get('event_timestamp') - status = game_info.get('status', '') - - # Include: - # - Past games from last 8 days - # - Upcoming games within next 6 weeks - # - Live games (any status that's not NS/AP/FT/F) - if status not in ['NS', 'AP', 'FT', 'F', '']: - # Live or in-progress game - game_data.append(game_info) - elif event_ts: - if event_ts >= eight_days_ago and event_ts <= six_weeks_from_now: - game_data.append(game_info) - else: - # No timestamp but valid status - include it - game_data.append(game_info) - - if not game_data: - return self.translate('commands.sports.no_games_league', sport=league_info.get('sport', 'unknown')) - - # Sort by game time (earliest first, but prioritize live games) - game_data.sort(key=lambda x: x['timestamp']) - - # Format responses with sport emojis, building up to 130 characters - sport_emoji = self.SPORT_EMOJIS.get(league_info['sport'], '🏆') + # Format responses with sport emojis + sport_emoji = SPORT_EMOJIS.get(league_info['sport'], '🏆') responses = [] current_length = 0 max_length = 130 - for game in game_data: + for game in all_event_data: game_str = f"{sport_emoji} {game['formatted']}" - - # Check if adding this game would exceed limit if responses: test_length = current_length + len("\n") + len(game_str) else: @@ -1412,319 +392,16 @@ class SportsCommand(BaseCommand): responses.append(game_str) current_length = test_length else: - # Can't fit more games - stop before exceeding limit break - if not responses: - # If even the first game doesn't fit, return it anyway (truncated) - return f"{sport_emoji} {game_data[0]['formatted'][:120]}" - return "\n".join(responses) except Exception as e: - self.logger.error(f"Error fetching league scores from TheSportsDB: {e}") - return self.translate('commands.sports.error_fetching_league', sport=league_info.get('sport', 'unknown')) + self.logger.error(f"Error getting league scores from TheSportsDB: {e}") + return self.translate('commands.sports.error_fetching_league', sport=league_info['sport']) + - def parse_thesportsdb_league_event(self, event: Dict, sport: str, league: str) -> Optional[Dict]: - """Parse a TheSportsDB league event and return structured data with timestamp for sorting - - Similar to parse_thesportsdb_event but doesn't require a specific team_id. - """ - try: - # Extract team info - home_team = event.get('strHomeTeam', '') - away_team = event.get('strAwayTeam', '') - home_score = event.get('intHomeScore', '') - away_score = event.get('intAwayScore', '') - status = event.get('strStatus', '') - timestamp_str = event.get('strTimestamp', '') - date_str = event.get('dateEvent', '') - time_str = event.get('strTime', '') - - # Get team abbreviations - home_abbr = self._get_team_abbreviation_from_name(home_team) - away_abbr = self._get_team_abbreviation_from_name(away_team) - - # Get timestamp for sorting - timestamp = 0 - event_timestamp = None - if timestamp_str: - try: - # Parse ISO format timestamp - dt = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) - event_timestamp = dt.timestamp() - timestamp = event_timestamp - except: - # Try parsing date and time separately - if date_str and time_str: - try: - dt_str = f"{date_str} {time_str}" - dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S") - # Assume UTC if no timezone info - dt = dt.replace(tzinfo=timezone.utc) - event_timestamp = dt.timestamp() - timestamp = event_timestamp - except: - pass - - # Format based on status - if status in ['FT', 'F']: # Full Time / Final - # Completed game - date_suffix = "" - if date_str: - try: - dt = datetime.strptime(date_str, "%Y-%m-%d") - today = datetime.now().date() - game_date = dt.date() - if game_date != today: - date_suffix = f", {self.format_clean_date(dt)}" - except: - pass - - formatted = f"{away_abbr} {away_score}-{home_score} @{home_abbr} (F{date_suffix})" - timestamp = 9999999998 # Final games second to last - - elif status in ['NS', 'AP', '']: # Not Started / Approved / Empty - # Scheduled game - if timestamp_str or (date_str and time_str): - try: - if timestamp_str: - dt = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) - else: - dt_str = f"{date_str} {time_str}" - dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S") - dt = dt.replace(tzinfo=timezone.utc) - - local_dt = dt.astimezone() - time_str_formatted = self.format_clean_date_time(local_dt) - - formatted = f"{away_abbr} @ {home_abbr} ({time_str_formatted})" - except: - formatted = f"{away_abbr} @ {home_abbr} (TBD)" - timestamp = 9999999999 # Put TBD games last - else: - formatted = f"{away_abbr} @ {home_abbr} (TBD)" - timestamp = 9999999999 # Put TBD games last - else: - # Other status (live game, postponed, etc.) - formatted = f"{away_abbr} {away_score or '0'}-{home_score or '0'} @{home_abbr} ({status})" - timestamp = -1 if status not in ['NS', 'AP'] else 9999999997 - - return { - 'timestamp': timestamp, - 'event_timestamp': event_timestamp, - 'formatted': formatted, - 'sport': sport, - 'status': status - } - - except Exception as e: - self.logger.error(f"Error parsing TheSportsDB league event: {e}") - return None - def parse_league_game_event(self, event: Dict, sport: str, league: str) -> Optional[Dict]: - """Parse a league game event and return structured data with timestamp for sorting""" - try: - competitions = event.get('competitions', []) - if not competitions: - return None - - competition = competitions[0] - competitors = competition.get('competitors', []) - - if len(competitors) != 2: - return None - - # Extract team info - team1 = competitors[0] - team2 = competitors[1] - - # Determine home/away teams for all sports - home_team = team1 if team1.get('homeAway') == 'home' else team2 - away_team = team2 if team1.get('homeAway') == 'home' else team1 - home_team_id = home_team.get('team', {}).get('id', '') - away_team_id = away_team.get('team', {}).get('id', '') - home_abbreviation = home_team.get('team', {}).get('abbreviation', 'UNK') - away_abbreviation = away_team.get('team', {}).get('abbreviation', 'UNK') - home_name = self.get_team_abbreviation(home_team_id, home_abbreviation, sport, league) - away_name = self.get_team_abbreviation(away_team_id, away_abbreviation, sport, league) - home_score = self.extract_score(home_team) - away_score = self.extract_score(away_team) - - # Keep original variables for backward compatibility - team1_name = away_name # away team first - team2_name = home_name # home team second (gets @ symbol) - team1_score = away_score - team2_score = home_score - - # Get game status - # In schedule endpoint, status is in competition, not event - status = competition.get('status', event.get('status', {})) - status_type = status.get('type', {}) - status_name = status_type.get('name', 'UNKNOWN') - - # Get timestamp for sorting - date_str = event.get('date', '') - timestamp = 0 # Default for sorting - event_timestamp = None - if date_str: - try: - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - event_timestamp = dt.timestamp() - timestamp = event_timestamp - except: - pass - - # Format based on game status - 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': - # For soccer, use displayClock if available (e.g., "90'+5'"), otherwise use half - # For soccer, show home team first (traditional soccer format) - if clock and clock != '0:00' and clock != "0'": - period_str = clock # Use displayClock directly (e.g., "90'+5'") - formatted = f"@{home_name} {home_score}-{away_score} {away_name} ({period_str})" - else: - period_str = f"{period}H" # Fallback to half - formatted = f"@{home_name} {home_score}-{away_score} {away_name} ({clock} {period_str})" - elif sport == 'baseball': - # Use shortDetail for ongoing baseball games to show top/bottom of inning - short_detail = status_type.get('shortDetail', '') - if short_detail and ('Top' in short_detail or 'Bottom' in short_detail): - 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 - - elif status_name == 'STATUS_SCHEDULED': - # Game is scheduled - if date_str: - try: - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - local_dt = dt.astimezone() - time_str = self.format_clean_date_time(local_dt) - if sport == 'soccer': - formatted = f"@{home_name} vs. {away_name} ({time_str})" - else: - formatted = f"{away_name} @ {home_name} ({time_str})" - except: - if sport == 'soccer': - formatted = f"@{home_name} vs. {away_name} (TBD)" - else: - formatted = f"{away_name} @ {home_name} (TBD)" - timestamp = 9999999999 # Put TBD games last - else: - if sport == 'soccer': - formatted = f"@{home_name} vs. {away_name} (TBD)" - else: - formatted = f"{away_name} @ {home_name} (TBD)" - timestamp = 9999999999 # Put TBD games last - - elif status_name == 'STATUS_HALFTIME': - # Game is at halftime - if sport == 'soccer': - formatted = f"@{home_name} {home_score}-{away_score} {away_name} (HT)" - else: - formatted = f"{away_name} {away_score}-{home_score} @{home_name} (HT)" - timestamp = -2 # Halftime games second priority after live games - elif status_name == 'STATUS_FULL_TIME': - # Soccer game is finished - put these last - # Check if game was played today or on a different day - date_suffix = "" - if date_str: - try: - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - local_dt = dt.astimezone() - today = datetime.now().date() - game_date = local_dt.date() - if game_date != today: - date_suffix = f", {self.format_clean_date(local_dt)}" - except: - pass - formatted = f"@{home_name} {home_score}-{away_score} {away_name} (FT{date_suffix})" - timestamp = 9999999998 # Final games second to last - elif status_name == 'STATUS_FINAL_PEN': - # Soccer game finished in penalty shootout - # Check if game was played today or on a different day - date_suffix = "" - if date_str: - try: - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - local_dt = dt.astimezone() - today = datetime.now().date() - game_date = local_dt.date() - if game_date != today: - date_suffix = f", {self.format_clean_date(local_dt)}" - except: - pass - - # Get penalty shootout scores - home_shootout = self.extract_shootout_score(home_team) - away_shootout = self.extract_shootout_score(away_team) - - # Format with penalty shootout result - if home_shootout is not None and away_shootout is not None: - formatted = f"@{home_name} {home_score}-{away_score} {away_name} (FT-PEN {home_shootout}-{away_shootout}{date_suffix})" - else: - formatted = f"@{home_name} {home_score}-{away_score} {away_name} (FT-PEN{date_suffix})" - - timestamp = 9999999998 # Final games second to last - elif status_name == 'STATUS_FINAL': - # Other sports game is finished - put these last - # Check if game was played today or on a different day - date_suffix = "" - if date_str: - try: - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - local_dt = dt.astimezone() - today = datetime.now().date() - game_date = local_dt.date() - if game_date != today: - date_suffix = f", {self.format_clean_date(local_dt)}" - except: - pass - formatted = f"{away_name} {away_score}-{home_score} @{home_name} (F{date_suffix})" - timestamp = 9999999998 # Final games second to last - - else: - # Other status - if sport == 'soccer': - formatted = f"@{home_name} {home_score}-{away_score} {away_name} ({status_name})" - else: - formatted = f"{away_name} {away_score}-{home_score} @{home_name} ({status_name})" - timestamp = 9999999997 # Other statuses third to last - - return { - 'timestamp': timestamp, - 'event_timestamp': event_timestamp, - 'formatted': formatted, - 'sport': sport, - 'status': status_name - } - - except Exception as e: - self.logger.error(f"Error parsing league game event: {e}") - return None async def get_team_scores(self, team_name: str) -> str: """Get scores for a specific team or league""" @@ -1741,7 +418,7 @@ class SportsCommand(BaseCommand): return await self.get_league_scores(league_info) # Otherwise, treat as team query - team_info = self.TEAM_MAPPINGS.get(team_name_clean) + team_info = TEAM_MAPPINGS.get(team_name_clean) if not team_info: return self.translate('commands.sports.team_not_found', team=team_name_clean) @@ -1766,7 +443,7 @@ class SportsCommand(BaseCommand): return await self.get_city_scores(city_teams, team_name) # Otherwise, treat as single team query - team_info = self.TEAM_MAPPINGS.get(team_name) + team_info = TEAM_MAPPINGS.get(team_name) if not team_info: return self.translate('commands.sports.team_not_found', team=team_name) @@ -1789,7 +466,7 @@ class SportsCommand(BaseCommand): # 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'], '🏆') + sport_emoji = SPORT_EMOJIS.get(team_info['sport'], '🏆') formatted_games = [] current_length = 0 max_length = 125 # Leave buffer to avoid cutoff @@ -1798,7 +475,7 @@ class SportsCommand(BaseCommand): # 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(): + if game_formatted and game_formatted[0] in SPORT_EMOJIS.values(): game_formatted = game_formatted[1:].strip() game_str = f"{sport_emoji} {game_formatted}" @@ -1819,7 +496,7 @@ class SportsCommand(BaseCommand): 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(): + if game_formatted and game_formatted[0] in SPORT_EMOJIS.values(): game_formatted = game_formatted[1:].strip() return f"{sport_emoji} {game_formatted[:120]}" @@ -1842,43 +519,32 @@ class SportsCommand(BaseCommand): # Default to ESPN API 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 + # Use team schedule endpoint via client + # The client already parses the events + all_games = await self.espn_client.fetch_team_schedule( + team_info['sport'], team_info['league'], team_info['team_id'] + ) if not all_games: return [] + # Track event IDs for live games to fetch real-time scores + live_event_ids = [] + for i, game_data in enumerate(all_games): + if game_data['timestamp'] < 0: # Negative timestamp indicates live game + event_id = game_data.get('id') + if event_id: + live_event_ids.append((event_id, i)) + # 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']) + live_event_data = await self.espn_client.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( + updated_game = self.espn_client.parse_game_event_with_timestamp( live_event_data, team_info['team_id'], team_info['sport'], team_info['league'] ) if updated_game: @@ -1953,328 +619,14 @@ class SportsCommand(BaseCommand): return [] async def fetch_team_games_thesportsdb(self, team_info: Dict[str, str]) -> List[Dict]: - """Fetch team games from TheSportsDB API - - Returns games sorted by relevance: - - Last completed game (if within last 8 days) - - Next scheduled game (if known) - """ + """Fetch team games from TheSportsDB API via client""" if not self.thesportsdb_client: self.logger.error("TheSportsDB client not initialized") return [] - try: - team_id = team_info['team_id'] - - # Fetch last events and next events - # Run in executor to avoid blocking - import asyncio - loop = asyncio.get_event_loop() - - last_events_task = loop.run_in_executor( - None, - lambda: self.thesportsdb_client.get_team_events_last(team_id, limit=5) - ) - next_events_task = loop.run_in_executor( - None, - lambda: self.thesportsdb_client.get_team_events_next(team_id, limit=5) - ) - - last_events, next_events = await asyncio.gather(last_events_task, next_events_task) - - # Parse events - all_games = [] - - # Parse last events (completed games) - for event in last_events: - game_data = self.parse_thesportsdb_event(event, team_id, team_info['sport'], team_info['league']) - if game_data: - all_games.append(game_data) - - # Parse next events (upcoming games) - for event in next_events: - game_data = self.parse_thesportsdb_event(event, team_id, team_info['sport'], team_info['league']) - if game_data: - all_games.append(game_data) - - if not all_games: - return [] - - # Get current time for comparison - now = datetime.now(timezone.utc).timestamp() - eight_days_ago = now - (8 * 24 * 60 * 60) - six_weeks_from_now = now + (6 * 7 * 24 * 60 * 60) - - # Separate into categories - upcoming_games = [] - past_games = [] - - for game in all_games: - game_event_ts = game.get('event_timestamp') - effective_ts = game_event_ts if game_event_ts is not None else game['timestamp'] - - if effective_ts is None: - # No timestamp, check status - if game.get('status') == 'FT' or game.get('status') == 'F': - past_games.append((now, game)) - else: - upcoming_games.append((six_weeks_from_now, game)) - elif effective_ts > now: - # Future game - only include if within next 6 weeks - if 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 >= 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: - # 1. Last completed game (if within last 8 days) - # 2. Next scheduled game (if known and within 6 weeks) - result = [] - - if past_games: - result.append(past_games[0][1]) # Most recent past game - - if upcoming_games: - result.append(upcoming_games[0][1]) # Next upcoming game - - return result - - except Exception as e: - self.logger.error(f"Error fetching team games from TheSportsDB: {e}") - return [] - - def parse_thesportsdb_event(self, event: Dict, team_id: str, sport: str, league: str) -> Optional[Dict]: - """Parse a TheSportsDB event and return structured data with timestamp for sorting""" - try: - # Extract team info - home_team = event.get('strHomeTeam', '') - away_team = event.get('strAwayTeam', '') - home_score = event.get('intHomeScore', '') - away_score = event.get('intAwayScore', '') - status = event.get('strStatus', '') - timestamp_str = event.get('strTimestamp', '') - date_str = event.get('dateEvent', '') - time_str = event.get('strTime', '') - - # Determine if our team is home or away - our_team_id = str(team_id) - event_home_id = str(event.get('idHomeTeam', '')) - event_away_id = str(event.get('idAwayTeam', '')) - - is_home = (event_home_id == our_team_id) - - # Get team abbreviations (use short names if available, otherwise use team names) - # For now, use a simplified version of team names - home_abbr = self._get_team_abbreviation_from_name(home_team) - away_abbr = self._get_team_abbreviation_from_name(away_team) - - # Get timestamp for sorting - timestamp = 0 - event_timestamp = None - if timestamp_str: - try: - # Parse ISO format timestamp - dt = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) - event_timestamp = dt.timestamp() - timestamp = event_timestamp - except: - # Try parsing date and time separately - if date_str and time_str: - try: - dt_str = f"{date_str} {time_str}" - dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S") - # Assume UTC if no timezone info - dt = dt.replace(tzinfo=timezone.utc) - event_timestamp = dt.timestamp() - timestamp = event_timestamp - except: - pass - - # Format based on status - if status in ['FT', 'F']: # Full Time / Final - # Completed game - date_suffix = "" - if date_str: - try: - dt = datetime.strptime(date_str, "%Y-%m-%d") - today = datetime.now().date() - game_date = dt.date() - if game_date != today: - date_suffix = f", {self.format_clean_date(dt)}" - except: - pass - - if is_home: - formatted = f"{away_abbr} {away_score}-{home_score} @{home_abbr} (F{date_suffix})" - else: - formatted = f"{home_abbr} {home_score}-{away_score} @{away_abbr} (F{date_suffix})" - - timestamp = 9999999998 # Final games second to last - - elif status in ['NS', 'AP', '']: # Not Started / Approved / Empty - # Scheduled game - if timestamp_str or (date_str and time_str): - try: - if timestamp_str: - dt = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) - else: - dt_str = f"{date_str} {time_str}" - dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S") - dt = dt.replace(tzinfo=timezone.utc) - - local_dt = dt.astimezone() - time_str_formatted = self.format_clean_date_time(local_dt) - - if is_home: - formatted = f"{away_abbr} @ {home_abbr} ({time_str_formatted})" - else: - formatted = f"{home_abbr} @ {away_abbr} ({time_str_formatted})" - except: - if is_home: - formatted = f"{away_abbr} @ {home_abbr} (TBD)" - else: - formatted = f"{home_abbr} @ {away_abbr} (TBD)" - timestamp = 9999999999 # Put TBD games last - else: - if is_home: - formatted = f"{away_abbr} @ {home_abbr} (TBD)" - else: - formatted = f"{home_abbr} @ {away_abbr} (TBD)" - timestamp = 9999999999 # Put TBD games last - else: - # Other status (live game, postponed, etc.) - if is_home: - formatted = f"{away_abbr} {away_score or '0'}-{home_score or '0'} @{home_abbr} ({status})" - else: - formatted = f"{home_abbr} {home_score or '0'}-{away_score or '0'} @{away_abbr} ({status})" - timestamp = -1 if status not in ['NS', 'AP'] else 9999999997 - - return { - 'timestamp': timestamp, - 'event_timestamp': event_timestamp, - 'formatted': formatted, - 'sport': sport, - 'status': status - } - - except Exception as e: - self.logger.error(f"Error parsing TheSportsDB event: {e}") - return None - - def _get_team_abbreviation_from_name(self, team_name: str) -> str: - """Extract a short abbreviation from a team name - - Uses common city abbreviations for WHL teams. - """ - if not team_name: - return 'UNK' - - # WHL team abbreviation mappings - whl_abbreviations = { - 'seattle thunderbirds': 'SEA', - 'portland winterhawks': 'POR', - 'everett silvertips': 'EVE', - 'spokane chiefs': 'SPO', - 'vancouver giants': 'VAN', - 'kamloops blazers': 'KAM', - 'prince george cougars': 'PG', - 'kelowna rockets': 'KEL', - 'tri-city americans': 'TC', - 'wenatchee wild': 'WEN', - 'victoria royals': 'VIC', - 'edmonton oil kings': 'EDM', - 'calgary hitmen': 'CGY', - 'red deer rebels': 'RD', - 'medicine hat tigers': 'MH', - 'lethbridge hurricanes': 'LET', - 'swift current broncos': 'SC', - 'moose jaw warriors': 'MJ', - 'regina pats': 'REG', - 'saskatoon blades': 'SAS', - 'prince albert raiders': 'PA', - 'brandon wheat kings': 'BDN', - 'winnipeg ice': 'WPG', - } - - team_lower = team_name.lower() - if team_lower in whl_abbreviations: - return whl_abbreviations[team_lower] - - # Try to extract from city name (first word) - words = team_name.split() - if len(words) >= 2: - city = words[0] - # Use common city abbreviations - city_abbr = { - 'seattle': 'SEA', - 'portland': 'POR', - 'everett': 'EVE', - 'spokane': 'SPO', - 'vancouver': 'VAN', - 'kamloops': 'KAM', - 'prince': 'PG', # Prince George (could also be Prince Albert, but PG is more common) - 'kelowna': 'KEL', - 'tri-city': 'TC', - 'tri city': 'TC', - 'tricity': 'TC', - 'wenatchee': 'WEN', - 'victoria': 'VIC', - 'edmonton': 'EDM', - 'calgary': 'CGY', - 'red': 'RD', # Red Deer - 'medicine': 'MH', # Medicine Hat - 'lethbridge': 'LET', - 'swift': 'SC', # Swift Current - 'moose': 'MJ', # Moose Jaw - 'regina': 'REG', - 'saskatoon': 'SAS', - 'prince albert': 'PA', - 'brandon': 'BDN', - } - city_lower = city.lower() - if city_lower in city_abbr: - return city_abbr[city_lower] - - # Fallback: use first 3 letters of city - if len(city) >= 3: - return city[:3].upper() - - # Final fallback: use first 3 letters of team name - return team_name[:3].upper() if len(team_name) >= 3 else team_name.upper() - - 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 + return await self.thesportsdb_client.fetch_team_games( + team_info['sport'], team_info['league'], team_info['team_id'] + ) 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 @@ -2300,96 +652,46 @@ class SportsCommand(BaseCommand): # Default to ESPN API 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 = [] - 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) + # The client already parses these events + all_games = await self.espn_client.fetch_team_schedule( + team_info['sport'], team_info['league'], team_info['team_id'] + ) if not all_games: return [] # Get current time for comparison now = datetime.now(timezone.utc).timestamp() + # 1 hour buffer for ongoing games + one_hour_ago = now - 3600 - # Filter to only upcoming games - upcoming_games = [] - for game in all_games: - # Skip live games (negative timestamps) - if game['timestamp'] < 0: - continue + parsed_games = [] + # We already have parsed games, but we need to filter/re-format them for schedule view + for game_data in all_games: + # Check if game is in the future or started very recently + ts = game_data.get('event_timestamp') or game_data['timestamp'] - game_event_ts = game.get('event_timestamp') - effective_ts = game_event_ts if game_event_ts is not None else game['timestamp'] - - # Only include games with valid future timestamps - if effective_ts is not None and effective_ts > now: - upcoming_games.append((effective_ts, game)) + # If timestamp is negative (live), it's definitely something we could show + # Otherwise check if it's in the future or within the last hour + if game_data['timestamp'] < 0 or ts >= one_hour_ago: + parsed_games.append(game_data) # Sort by soonest first - upcoming_games.sort(key=lambda x: x[0] if x[0] is not None else float('inf')) - - # Return all upcoming games (caller will limit by message length) - return [g for _, g in upcoming_games] + parsed_games.sort(key=lambda x: x.get('event_timestamp') or x['timestamp']) + return parsed_games except Exception as e: self.logger.error(f"Error fetching team schedule: {e}") return [] async def fetch_team_schedule_thesportsdb(self, team_info: Dict[str, str]) -> List[Dict]: - """Fetch upcoming scheduled games for a team from TheSportsDB""" + """Fetch upcoming scheduled games for a team from TheSportsDB via client""" if not self.thesportsdb_client: - self.logger.error("TheSportsDB client not initialized") return [] - try: - team_id = team_info['team_id'] - - # Fetch next events - import asyncio - loop = asyncio.get_event_loop() - - next_events = await loop.run_in_executor( - None, - lambda: self.thesportsdb_client.get_team_events_next(team_id, limit=10) - ) - - # Parse events - upcoming_games = [] - now = datetime.now(timezone.utc).timestamp() - - for event in next_events: - game_data = self.parse_thesportsdb_event(event, team_id, team_info['sport'], team_info['league']) - if game_data: - game_event_ts = game_data.get('event_timestamp') - effective_ts = game_event_ts if game_event_ts is not None else game_data['timestamp'] - - # Only include future games - if effective_ts is None or effective_ts > now: - upcoming_games.append((effective_ts or float('inf'), game_data)) - - # Sort by soonest first - upcoming_games.sort(key=lambda x: x[0] if x[0] is not None else float('inf')) - - return [g for _, g in upcoming_games] - - except Exception as e: - self.logger.error(f"Error fetching team schedule from TheSportsDB: {e}") - return [] + return await self.thesportsdb_client.fetch_team_schedule( + team_info['sport'], team_info['league'], team_info['team_id'] + ) async def fetch_team_schedule_formatted(self, team_info: Dict[str, str]) -> Optional[str]: """Fetch and format upcoming scheduled games for a team @@ -2402,7 +704,7 @@ class SportsCommand(BaseCommand): # 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'], '🏆') + sport_emoji = SPORT_EMOJIS.get(team_info['sport'], '🏆') formatted_games = [] current_length = 0 max_length = 125 # Leave buffer to avoid cutoff @@ -2411,7 +713,7 @@ class SportsCommand(BaseCommand): # 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(): + if game_formatted and game_formatted[0] in SPORT_EMOJIS.values(): game_formatted = game_formatted[1:].strip() game_str = f"{sport_emoji} {game_formatted}" @@ -2432,357 +734,55 @@ class SportsCommand(BaseCommand): 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(): + if game_formatted and game_formatted[0] in 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""" - try: - competitions = event.get('competitions', []) - if not competitions: - return None - - competition = competitions[0] - competitors = competition.get('competitors', []) - - if len(competitors) != 2: - return None - - # Check if our team is in this game - our_team = None - other_team = None - - for competitor in competitors: - if competitor.get('team', {}).get('id') == team_id: - our_team = competitor - else: - other_team = competitor - - if not our_team or not other_team: - return None - - # Determine home/away teams for all sports - home_team = our_team if our_team.get('homeAway') == 'home' else other_team - away_team = other_team if our_team.get('homeAway') == 'home' else our_team - home_team_id = home_team.get('team', {}).get('id', '') - away_team_id = away_team.get('team', {}).get('id', '') - home_abbreviation = home_team.get('team', {}).get('abbreviation', 'UNK') - away_abbreviation = away_team.get('team', {}).get('abbreviation', 'UNK') - home_name = self.get_team_abbreviation(home_team_id, home_abbreviation, sport, league) - away_name = self.get_team_abbreviation(away_team_id, away_abbreviation, sport, league) - home_score = self.extract_score(home_team) - away_score = self.extract_score(away_team) - - # For individual team queries, we still want to show our team first - # but in the correct home/away order for each sport - if our_team.get('homeAway') == 'home': - our_team_name = home_name - other_team_name = away_name - our_score = home_score - other_score = away_score - else: - our_team_name = away_name - other_team_name = home_name - our_score = away_score - other_score = home_score - - # Get game status - # In schedule endpoint, status is in competition, not event - status = competition.get('status', event.get('status', {})) - status_type = status.get('type', {}) - status_name = status_type.get('name', 'UNKNOWN') - - # Get timestamp for sorting - date_str = event.get('date', '') - timestamp = 0 # Default for sorting - event_timestamp = None - if date_str: - try: - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - event_timestamp = dt.timestamp() - timestamp = event_timestamp - except: - pass - - # Format based on game status - 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': - # For soccer, use displayClock if available (e.g., "90'+5'"), otherwise use half - # For soccer, show home team first (traditional soccer format) - if clock and clock != '0:00' and clock != "0'": - period_str = clock # Use displayClock directly (e.g., "90'+5'") - formatted = f"@{home_name} {home_score}-{away_score} {away_name} ({period_str})" - else: - period_str = f"{period}H" # Fallback to half - formatted = f"@{home_name} {home_score}-{away_score} {away_name} ({clock} {period_str})" - elif sport == 'baseball': - # Use shortDetail for ongoing baseball games to show top/bottom of inning - short_detail = status.get('type', {}).get('shortDetail', '') - if short_detail and ('Top' in short_detail or 'Bottom' in short_detail): - period_str = short_detail # e.g., "Top 14th", "Bottom 9th" - else: - period_str = f"{period}I" # Fallback to inning number only - 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 (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 - - elif status_name == 'STATUS_SCHEDULED': - # Game is scheduled - if date_str: - try: - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - local_dt = dt.astimezone() - time_str = self.format_clean_date_time(local_dt) - if sport == 'soccer': - formatted = f"@{home_name} vs. {away_name} ({time_str})" - else: - formatted = f"{away_name} @ {home_name} ({time_str})" - except: - if sport == 'soccer': - formatted = f"@{home_name} vs. {away_name} (TBD)" - else: - formatted = f"{away_name} @ {home_name} (TBD)" - timestamp = 9999999999 # Put TBD games last - else: - if sport == 'soccer': - formatted = f"@{home_name} vs. {away_name} (TBD)" - else: - formatted = f"{away_name} @ {home_name} (TBD)" - timestamp = 9999999999 # Put TBD games last - - elif status_name == 'STATUS_HALFTIME': - # Game is at halftime - if sport == 'soccer': - formatted = f"@{home_name} {home_score}-{away_score} {away_name} (HT)" - else: - formatted = f"{away_name} {away_score}-{home_score} @{home_name} (HT)" - timestamp = -2 # Halftime games second priority after live games - elif status_name == 'STATUS_FULL_TIME': - # Soccer game is finished - put these last - # Check if game was played today or on a different day - date_suffix = "" - if date_str: - try: - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - local_dt = dt.astimezone() - today = datetime.now().date() - game_date = local_dt.date() - if game_date != today: - date_suffix = f", {self.format_clean_date(local_dt)}" - except: - pass - formatted = f"@{home_name} {home_score}-{away_score} {away_name} (FT{date_suffix})" - timestamp = 9999999998 # Final games second to last - elif status_name == 'STATUS_FINAL_PEN': - # Soccer game finished in penalty shootout - # Check if game was played today or on a different day - date_suffix = "" - if date_str: - try: - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - local_dt = dt.astimezone() - today = datetime.now().date() - game_date = local_dt.date() - if game_date != today: - date_suffix = f", {self.format_clean_date(local_dt)}" - except: - pass - - # Get penalty shootout scores - home_shootout = self.extract_shootout_score(home_team) - away_shootout = self.extract_shootout_score(away_team) - - # Format with penalty shootout result - if home_shootout is not None and away_shootout is not None: - formatted = f"@{home_name} {home_score}-{away_score} {away_name} (FT-PEN {home_shootout}-{away_shootout}{date_suffix})" - else: - formatted = f"@{home_name} {home_score}-{away_score} {away_name} (FT-PEN{date_suffix})" - - timestamp = 9999999998 # Final games second to last - elif status_name == 'STATUS_FINAL': - # Other sports game is finished - put these last - # Check if game was played today or on a different day - date_suffix = "" - if date_str: - try: - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - local_dt = dt.astimezone() - today = datetime.now().date() - game_date = local_dt.date() - if game_date != today: - date_suffix = f", {self.format_clean_date(local_dt)}" - except: - pass - formatted = f"{away_name} {away_score}-{home_score} @{home_name} (F{date_suffix})" - timestamp = 9999999998 # Final games second to last - - else: - # Other status - if sport == 'soccer': - formatted = f"@{home_name} {home_score}-{away_score} {away_name} ({status_name})" - else: - formatted = f"{away_name} {away_score}-{home_score} @{home_name} ({status_name})" - timestamp = 9999999997 # Other statuses third to last - - return { - 'timestamp': timestamp, - 'event_timestamp': event_timestamp, - 'formatted': formatted, - 'sport': sport, - 'status': status_name - } - - except Exception as e: - self.logger.error(f"Error parsing game event with timestamp: {e}") - return None - def parse_game_event(self, event: Dict, team_id: str) -> Optional[str]: - """Parse a game event and return formatted score info""" + async def execute(self, message: MeshMessage) -> bool: + """Main entry point for command execution""" try: - competitions = event.get('competitions', []) - if not competitions: - return None + # Record execution for this user (handles cooldown) + self.record_execution(message.sender_id) - competition = competitions[0] - competitors = competition.get('competitors', []) + content = message.content.strip() + if content.startswith('!'): + content = content[1:].strip() - if len(competitors) != 2: - return None - - # Check if our team is in this game - our_team = None - other_team = None - - for competitor in competitors: - if competitor.get('team', {}).get('id') == team_id: - our_team = competitor + # Parse command: !sports [query] + parts = content.split(' ', 1) + if len(parts) < 2: + # Check if this channel has an override team + if not message.is_dm and message.channel in self.channel_overrides: + override_team = self.channel_overrides[message.channel] + response = await self.get_team_scores(override_team) else: - other_team = competitor + response = await self.get_default_teams_scores() + return await self.send_response(message, response) - if not our_team or not other_team: - return None + query = parts[1].strip() - # Extract team info - our_team_name = our_team.get('team', {}).get('abbreviation', 'UNK') - other_team_name = other_team.get('team', {}).get('abbreviation', 'UNK') + # Check if it's a league query (e.g., "nfl", "mlb", etc.) + league_info = self.get_league_info(query) + if league_info: + scores = await self.get_league_scores(league_info) + return await self.send_response(message, scores) - # Determine home/away teams - our_home_away = our_team.get('homeAway', '') - other_home_away = other_team.get('homeAway', '') + # Check if it's a city query (e.g., "seattle") + city_teams = self.get_city_teams(query) + if city_teams: + scores = await self.get_city_scores(city_teams, query) + return await self.send_response(message, scores) - if our_home_away == 'home': - home_team_name = our_team_name - away_team_name = other_team_name - elif other_home_away == 'home': - home_team_name = other_team_name - away_team_name = our_team_name - else: - # Fallback if homeAway is not available - home_team_name = other_team_name - away_team_name = our_team_name - - # Get scores - our_score = self.extract_score(our_team) - other_score = self.extract_score(other_team) - - # Get game status - status = event.get('status', {}) - status_type = status.get('type', {}) - status_name = status_type.get('name', 'UNKNOWN') - - # Format based on game status - if status_name in ['STATUS_IN_PROGRESS', 'STATUS_FIRST_HALF', 'STATUS_SECOND_HALF']: - # Game is live - clock = status.get('displayClock', '') - period = status.get('period', 0) + # Treat as team score query + scores = await self.get_team_scores(query) + if not scores: + return await self.send_response(message, f"No games found for {query}.") - # Format period based on sport (need to determine sport from team_info) - # This is a legacy method, so we'll use a generic approach - if period <= 2: - period_str = f"{period}H" # Likely soccer (halves) - elif period <= 4: - period_str = f"Q{period}" # Likely football (quarters) - else: - period_str = f"{period}I" # Likely baseball (innings) - - return f"{our_team_name} {our_score}-{other_score} @{other_team_name} ({clock} {period_str})" + return await self.send_response(message, scores) - elif status_name == 'STATUS_SCHEDULED': - # Game is scheduled - date_str = event.get('date', '') - if date_str: - try: - # Parse date and format - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - # Convert to local time (assuming Pacific for Seattle teams) - local_dt = dt.astimezone() - time_str = self.format_clean_date_time(local_dt) - return f"{away_team_name} @ {home_team_name} ({time_str})" - except: - return f"{away_team_name} @ {home_team_name} (TBD)" - else: - return f"{away_team_name} @ {home_team_name} (TBD)" - - elif status_name == 'STATUS_HALFTIME': - # Game is at halftime - return f"{our_team_name} {our_score}-{other_score} @{other_team_name} (HT)" - elif status_name == 'STATUS_FULL_TIME': - # Soccer game is finished - # Check if game was played today or on a different day - date_str = event.get('date', '') - date_suffix = "" - if date_str: - try: - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - local_dt = dt.astimezone() - today = datetime.now().date() - game_date = local_dt.date() - if game_date != today: - date_suffix = f", {self.format_clean_date(local_dt)}" - except: - pass - return f"{our_team_name} {our_score}-{other_score} @{other_team_name} (FT{date_suffix})" - elif status_name == 'STATUS_FINAL': - # Other sports game is finished - # Check if game was played today or on a different day - date_str = event.get('date', '') - date_suffix = "" - if date_str: - try: - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - local_dt = dt.astimezone() - today = datetime.now().date() - game_date = local_dt.date() - if game_date != today: - date_suffix = f", {self.format_clean_date(local_dt)}" - except: - pass - return f"{our_team_name} {our_score}-{other_score} @{other_team_name} (F{date_suffix})" - - else: - # Other status - return f"{our_team_name} {our_score}-{other_score} {other_team_name} ({status_name})" - except Exception as e: - self.logger.error(f"Error parsing game event: {e}") - return None + self.logger.error(f"Error in sports execute: {e}") + return await self.send_response(message, "Error processing sports command.") + diff --git a/modules/commands/stats_command.py b/modules/commands/stats_command.py index 269ebed..12be104 100644 --- a/modules/commands/stats_command.py +++ b/modules/commands/stats_command.py @@ -25,7 +25,12 @@ class StatsCommand(BaseCommand): description = "Show statistics for past 24 hours. Use 'stats messages', 'stats channels', or 'stats paths' for specific stats." category = "analytics" - def __init__(self, bot): + def __init__(self, bot: Any): + """Initialize the stats command. + + Args: + bot: The bot instance. + """ super().__init__(bot) self._load_config() self._init_stats_tables() @@ -295,6 +300,11 @@ class StatsCommand(BaseCommand): return path def get_help_text(self) -> str: + """Get help text for the stats command. + + Returns: + str: The help text for this command. + """ return self.translate('commands.stats.help') async def execute(self, message: MeshMessage) -> bool: