mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-07-03 04:41:41 +00:00
7537c76c63
- Introduced World Cup command and service settings in `config.ini.example`, `config.ini.minimal-example`, and `config.ini.quickstart` to enable FIFA World Cup score tracking and updates. - Enhanced `espn_client.py` with methods to fetch tournament calendar and match states, supporting the new World Cup features. - Updated `sports_command.py` to resolve World Cup nation names and integrate with the new command. - Added translations for World Cup commands and messages in multiple languages, ensuring comprehensive support for users. - Included mappings for World Cup nations in `sports_mappings.py` to facilitate accurate team identification.
540 lines
25 KiB
Python
540 lines
25 KiB
Python
import logging
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
import aiohttp
|
|
|
|
from .sports_mappings import format_clean_date, format_clean_date_time, get_team_abbreviation
|
|
|
|
|
|
class ESPNClient:
|
|
"""Client for ESPN API using aiohttp for asynchronous requests"""
|
|
|
|
BASE_URL = "http://site.api.espn.com/apis/site/v2/sports"
|
|
# Standings live under a different path prefix than the site v2 endpoints
|
|
STANDINGS_BASE_URL = "http://site.api.espn.com/apis/v2/sports"
|
|
|
|
def __init__(self, logger: Optional[logging.Logger] = None, timeout: int = 10, session: Optional[aiohttp.ClientSession] = None):
|
|
"""Initialize the ESPN API client.
|
|
|
|
Args:
|
|
logger: Logger instance for error and info logging. If None, creates a default logger.
|
|
timeout: Request timeout in seconds (default: 10)
|
|
session: Optional existing aiohttp session to reuse. If None, creates new sessions as needed.
|
|
"""
|
|
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_scoreboard_with_calendar(self, sport: str, league: str) -> Optional[dict]:
|
|
"""Fetch scoreboard plus tournament calendar/league metadata.
|
|
|
|
Returns a dict with:
|
|
- 'events': parsed game events (same shape as fetch_scoreboard)
|
|
- 'calendar': list of {startDate, endDate, label} stage entries (may be empty)
|
|
- 'league_name': human-readable league/tournament name
|
|
- 'competitors': raw competitor dicts seen in today's events (id/name/abbr/location)
|
|
|
|
Used by the World Cup command to determine whether a tournament is in season
|
|
and to resolve nation names when standings are unavailable. Returns None on error.
|
|
"""
|
|
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_raw = data.get('events', [])
|
|
parsed_events = []
|
|
competitors: list[dict] = []
|
|
for event in events_raw:
|
|
parsed = self.parse_league_game_event(event, sport, league)
|
|
if parsed:
|
|
parsed_events.append(parsed)
|
|
for comp in event.get('competitions', [{}])[0].get('competitors', []):
|
|
team = comp.get('team', {})
|
|
if team:
|
|
competitors.append(team)
|
|
|
|
leagues = data.get('leagues', [])
|
|
league_obj = leagues[0] if leagues else {}
|
|
|
|
# The calendar nests the real stages (Group, Round of 32, ... Final) under
|
|
# an 'entries' list inside a single top-level wrapper whose own end date spans
|
|
# the whole season. Flatten to the actual stages so callers get true windows.
|
|
calendar = []
|
|
|
|
def _add_cal(entry: dict) -> None:
|
|
if isinstance(entry, dict) and entry.get('startDate') and entry.get('endDate'):
|
|
calendar.append({
|
|
'label': entry.get('label', ''),
|
|
'startDate': entry['startDate'],
|
|
'endDate': entry['endDate'],
|
|
})
|
|
|
|
for entry in league_obj.get('calendar', []):
|
|
nested = entry.get('entries') if isinstance(entry, dict) else None
|
|
if isinstance(nested, list) and nested:
|
|
for sub in nested:
|
|
_add_cal(sub)
|
|
else:
|
|
_add_cal(entry)
|
|
|
|
return {
|
|
'events': parsed_events,
|
|
'calendar': calendar,
|
|
'league_name': league_obj.get('name', ''),
|
|
'competitors': competitors,
|
|
}
|
|
except Exception as e:
|
|
self.logger.error(f"ESPN fetch_scoreboard_with_calendar error for {sport}/{league}: {e}")
|
|
return None
|
|
|
|
async def fetch_team_fixtures(self, sport: str, league: str, team_id: str, start_date: str, end_date: str) -> list[dict]:
|
|
"""Fetch a team's matches across a date range via the dated scoreboard endpoint.
|
|
|
|
Unlike the team /schedule endpoint (which, for tournament national teams, only
|
|
returns matches already played), the dated scoreboard exposes the full set of
|
|
known fixtures. start_date/end_date are 'YYYYMMDD' strings. Returns parsed events
|
|
(past, live, and scheduled) involving team_id, or [] on error.
|
|
"""
|
|
url = f"{self.BASE_URL}/{sport}/{league}/scoreboard?dates={start_date}-{end_date}"
|
|
try:
|
|
session = await self._get_session()
|
|
async with session.get(url) as response:
|
|
response.raise_for_status()
|
|
data = await response.json()
|
|
|
|
results = []
|
|
for event in data.get('events', []):
|
|
competition = event.get('competitions', [{}])[0]
|
|
competitors = competition.get('competitors', [])
|
|
if any(str(c.get('team', {}).get('id', '')) == str(team_id) for c in competitors):
|
|
parsed = self.parse_game_event_with_timestamp(event, str(team_id), sport, league)
|
|
if parsed:
|
|
results.append(parsed)
|
|
return results
|
|
except Exception as e:
|
|
self.logger.error(f"ESPN fetch_team_fixtures error for {team_id} {start_date}-{end_date}: {e}")
|
|
return []
|
|
|
|
def _score_int(self, competitor: dict) -> int:
|
|
"""Best-effort integer score for a competitor (0 on failure)."""
|
|
try:
|
|
return int(float(self.extract_score(competitor)))
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
async def fetch_match_states(self, sport: str, league: str, cache_bust: bool = False) -> list[dict]:
|
|
"""Return current per-match live state for the scoreboard (today's matches).
|
|
|
|
Each item: {id, home_id, away_id, home_name, away_name, home_score, away_score,
|
|
status, clock, home_pen, away_pen}. Names are full team display names. Penalty
|
|
fields are None unless a shootout score is present. Used by the live-score service
|
|
to detect kickoff / goal / half-time / full-time transitions. Returns [] on error.
|
|
|
|
cache_bust appends a unique query param to bypass ESPN's edge cache, used when a
|
|
fastcast push signals a change so the REST snapshot reflects it immediately.
|
|
"""
|
|
url = f"{self.BASE_URL}/{sport}/{league}/scoreboard"
|
|
if cache_bust:
|
|
url += f"?_={int(time.time() * 1000)}"
|
|
try:
|
|
session = await self._get_session()
|
|
async with session.get(url) as response:
|
|
response.raise_for_status()
|
|
data = await response.json()
|
|
|
|
states = []
|
|
for event in data.get('events', []):
|
|
competition = event.get('competitions', [{}])[0]
|
|
competitors = competition.get('competitors', [])
|
|
if len(competitors) != 2:
|
|
continue
|
|
home = next((c for c in competitors if c.get('homeAway') == 'home'), competitors[0])
|
|
away = next((c for c in competitors if c.get('homeAway') == 'away'), competitors[1])
|
|
status_obj = competition.get('status', event.get('status', {}))
|
|
states.append({
|
|
'id': str(event.get('id', '')),
|
|
'home_id': str(home.get('team', {}).get('id', '')),
|
|
'away_id': str(away.get('team', {}).get('id', '')),
|
|
'home_name': home.get('team', {}).get('displayName', home.get('team', {}).get('name', '?')),
|
|
'away_name': away.get('team', {}).get('displayName', away.get('team', {}).get('name', '?')),
|
|
'home_score': self._score_int(home),
|
|
'away_score': self._score_int(away),
|
|
'status': status_obj.get('type', {}).get('name', 'UNKNOWN'),
|
|
'clock': status_obj.get('displayClock', ''),
|
|
'home_pen': self.extract_shootout_score(home),
|
|
'away_pen': self.extract_shootout_score(away),
|
|
})
|
|
return states
|
|
except Exception as e:
|
|
self.logger.error(f"ESPN fetch_match_states error for {sport}/{league}: {e}")
|
|
return []
|
|
|
|
@staticmethod
|
|
def _stat_value(stats: list[dict], *names: str) -> Optional[float]:
|
|
"""Look up a numeric stat value by its 'name' (or 'type') from a stats array."""
|
|
wanted = {n.lower() for n in names}
|
|
for stat in stats:
|
|
if str(stat.get('name', '')).lower() in wanted or str(stat.get('type', '')).lower() in wanted:
|
|
value = stat.get('value')
|
|
if isinstance(value, (int, float)):
|
|
return value
|
|
return None
|
|
|
|
async def fetch_standings(self, sport: str, league: str) -> list[dict]:
|
|
"""Fetch and parse group standings for a league.
|
|
|
|
Returns a list of groups, each:
|
|
{'group_name': 'Group A',
|
|
'entries': [{'rank', 'name', 'abbr', 'gp', 'w', 'd', 'l', 'pts', 'gd'}, ...]}
|
|
Entries are sorted by rank. Returns [] on error or if no standings exist.
|
|
"""
|
|
url = f"{self.STANDINGS_BASE_URL}/{sport}/{league}/standings"
|
|
try:
|
|
session = await self._get_session()
|
|
async with session.get(url) as response:
|
|
response.raise_for_status()
|
|
data = await response.json()
|
|
|
|
groups = []
|
|
for child in data.get('children', []):
|
|
standings = child.get('standings', {})
|
|
entries = []
|
|
for entry in standings.get('entries', []):
|
|
team = entry.get('team', {})
|
|
stats = entry.get('stats', [])
|
|
|
|
def _i(*names: str) -> int:
|
|
v = self._stat_value(stats, *names)
|
|
return int(v) if v is not None else 0
|
|
|
|
entries.append({
|
|
'rank': _i('rank'),
|
|
'id': str(team.get('id', '')),
|
|
'name': team.get('displayName', team.get('name', '')),
|
|
'abbr': team.get('abbreviation', ''),
|
|
'location': team.get('location', ''),
|
|
'gp': _i('gamesPlayed'),
|
|
'w': _i('wins'),
|
|
'd': _i('ties', 'draws'),
|
|
'l': _i('losses'),
|
|
'pts': _i('points'),
|
|
'gd': _i('pointDifferential'),
|
|
})
|
|
|
|
entries.sort(key=lambda e: e['rank'] if e['rank'] else 999)
|
|
groups.append({
|
|
'group_name': child.get('name', ''),
|
|
'entries': entries,
|
|
})
|
|
return groups
|
|
except Exception as e:
|
|
self.logger.error(f"ESPN fetch_standings 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: float = 0
|
|
event_timestamp: Optional[float] = 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:
|
|
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)
|