Add TheSportsDB client integration for enhanced sports data retrieval

- Introduced TheSportsDBClient class to handle API requests with rate limiting, allowing for efficient data fetching for teams and events.
- Updated SportsCommand to utilize TheSportsDB API for leagues not supported by ESPN, including Canadian Football League (CFL) and Western Hockey League (WHL) teams.
- Implemented methods for fetching team and league events, including upcoming and past games, with robust error handling and logging.
- Enhanced team and league score retrieval to support both ESPN and TheSportsDB APIs, improving overall sports command functionality.
This commit is contained in:
agessaman
2025-12-28 21:32:54 -08:00
parent 63ed0fd73d
commit 055db64ded
+843 -1
View File
@@ -22,12 +22,175 @@ Team IDs should be periodically verified, especially after:
import re
import json
import requests
import time
from datetime import datetime, timezone
from typing import List, Dict, Optional, Tuple
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 []
class SportsCommand(BaseCommand):
"""Handles sports commands with ESPN API integration"""
@@ -42,6 +205,9 @@ class SportsCommand(BaseCommand):
# ESPN API base URL
ESPN_BASE_URL = "http://site.api.espn.com/apis/site/v2/sports"
# TheSportsDB client for leagues not supported by ESPN
thesportsdb_client: Optional[TheSportsDBClient] = None
# Sport emojis for easy identification
SPORT_EMOJIS = {
'football': '🏈',
@@ -199,6 +365,40 @@ class SportsCommand(BaseCommand):
'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'},
@@ -439,6 +639,30 @@ class SportsCommand(BaseCommand):
# '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'},
'chiefs': {'sport': 'hockey', 'league': 'whl', 'team_id': '144381', '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'},
'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'},
# MLS Teams
'sounders': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9726'},
'seattle sounders': {'sport': 'soccer', 'league': 'usa.1', 'team_id': '9726'},
@@ -547,6 +771,9 @@ class SportsCommand(BaseCommand):
# Per-user cooldown tracking
self.user_cooldowns = {} # user_id -> last_execution_time
# Initialize TheSportsDB client
self.thesportsdb_client = TheSportsDBClient(logger=self.logger)
# Load default teams from config
self.default_teams = self.load_default_teams()
# Note: allowed_channels is now loaded by BaseCommand from config
@@ -799,6 +1026,10 @@ class SportsCommand(BaseCommand):
'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'},
@@ -820,6 +1051,10 @@ class SportsCommand(BaseCommand):
'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'},
@@ -982,6 +1217,11 @@ class SportsCommand(BaseCommand):
async def get_league_scores(self, league_info: Dict[str, str]) -> str:
"""Get upcoming games for a league"""
# Check if this league uses TheSportsDB
if league_info.get('api_source') == 'thesportsdb':
return await self.get_league_scores_thesportsdb(league_info)
# Default to ESPN API
try:
# Construct API URL
url = f"{self.ESPN_BASE_URL}/{league_info['sport']}/{league_info['league']}/scoreboard"
@@ -1031,6 +1271,249 @@ class SportsCommand(BaseCommand):
self.logger.error(f"Error fetching league scores: {e}")
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.
"""
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'))
league_id = league_info.get('league_id')
if not league_id:
league_name = league_info.get('league', 'unknown').upper()
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)
)
# 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)
day_events_tasks = [
loop.run_in_executor(None, make_day_fetcher(d))
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:] # 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'], '🏆')
responses = []
current_length = 0
max_length = 130
for game in game_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:
test_length = len(game_str)
if test_length <= max_length:
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'))
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:
@@ -1238,8 +1721,18 @@ class SportsCommand(BaseCommand):
async def get_team_scores(self, team_name: str) -> str:
"""Get scores for a specific team or league"""
# Check if this is a schedule query (team_name ends with " schedule")
if team_name.endswith(' schedule'):
is_schedule_query = team_name.endswith(' schedule')
if is_schedule_query:
team_name_clean = team_name[:-9].strip() # Remove " schedule"
# First check if it's a league query
league_info = self.get_league_info(team_name_clean)
if league_info:
# For league schedule queries, we can return upcoming games
# (which is essentially the schedule)
return await self.get_league_scores(league_info)
# Otherwise, treat as team query
team_info = self.TEAM_MAPPINGS.get(team_name_clean)
if not team_info:
return self.translate('commands.sports.team_not_found', team=team_name_clean)
@@ -1332,7 +1825,14 @@ class SportsCommand(BaseCommand):
- Live games first
- Last completed game (if within last 8 days)
- Next scheduled game (if known)
Supports both ESPN API and TheSportsDB API based on team_info['api_source'].
"""
# Check if this team uses TheSportsDB
if team_info.get('api_source') == 'thesportsdb':
return await self.fetch_team_games_thesportsdb(team_info)
# 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"
@@ -1444,6 +1944,300 @@ class SportsCommand(BaseCommand):
self.logger.error(f"Error fetching team games: {e}")
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)
"""
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
'kelowna': 'KEL',
'tri-city': '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',
'winnipeg': 'WPG',
'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
@@ -1487,7 +2281,14 @@ class SportsCommand(BaseCommand):
Returns as many upcoming games as available from the schedule endpoint.
Used for 'sports <teamname> schedule' command.
Supports both ESPN API and TheSportsDB API based on team_info['api_source'].
"""
# Check if this team uses TheSportsDB
if team_info.get('api_source') == 'thesportsdb':
return await self.fetch_team_schedule_thesportsdb(team_info)
# 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"
@@ -1539,6 +2340,47 @@ class SportsCommand(BaseCommand):
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"""
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 []
async def fetch_team_schedule_formatted(self, team_info: Dict[str, str]) -> Optional[str]:
"""Fetch and format upcoming scheduled games for a team