Files
meshcore-bot/modules/commands/sports_command.py
T
agessaman fc63039a48 fix: address Codex review of tonight's work
Correctness:

- {path_distance} was always blank in production. The resolution code that
  builds repeater_info dropped latitude/longitude in every branch, so the
  calculator could never find a coordinate. My tests passed hand-built
  dicts straight to the calculator and never exercised the builder, which
  is why they stayed green. Coordinates are now carried through all four
  construction sites, and the new tests drive _lookup_repeater_names via
  its lookup_func hook so the real builder runs.

- The #80 route guard was defeated two ways in the channel handler. When
  the RF data was an uncorrelated fallback, control fell through to the
  raw-hex and routing_info fallbacks below, which took the route from the
  unrelated packet anyway; the guard had actually made that path
  reachable. message.routing_info was also assigned unconditionally, and
  the path command reads it. "Not attributable" is now a terminal branch
  and the routing_info hand-off checks provenance.

- Same fix was incomplete for DMs: routing_info was captured and turned
  into path_info before the provenance check ran, so the later check only
  declined to overwrite an already-wrong value. Guarded at the source.

- Rendering could transmit for real. Capture only intercepts
  send_response, but advert calls send_advert() directly and
  send_response_chunked never checked capture_sink. Chunked sends are now
  captured, and rendering is opt-in via BaseCommand.render_safe (default
  False) instead of a denylist that cannot be complete. This also closes
  the DM-only leak: schedule is not marked safe, so {cmd:schedule} can no
  longer broadcast configuration to a channel.

- Multi-part rendered output was rejoined into one oversized send.
  Scheduled messages are now split to the RF body budget and sent through
  send_channel_messages_chunked, on character boundaries so multi-byte
  text is not corrupted.

- _last_path_distance_km is instance state that was only set on success,
  so an invalid path request could show the previous request's distance.
  Reset at the start of every execute().

- Stale-contact retries were only counted on non-OK results, so timeouts
  and exceptions left a contact eligible forever and could recreate the
  storm. All failed attempts count now.

Web viewer:

- A failed config reload was reported as a successful save. The API and
  UI now distinguish "saved and active" from "saved, restart needed".

- Preview count was unbounded; clamped to 1-20.

- update_ini_values is a read-modify-replace with no locking, so two
  concurrent viewer saves could lose one. Serialised behind a lock.
2026-08-22 00:16:03 -07:00

828 lines
36 KiB
Python

#!/usr/bin/env python3
"""
Sports command for the MeshCore Bot
Provides sports scores and schedules using ESPN API
API description via https://github.com/zuplo/espn-openapi/
Team ID Stability:
ESPN team IDs are generally stable but can change in certain circumstances:
- Team relocation or renaming
- Expansion teams (new teams added to leagues)
- ESPN data system updates
If a team returns "No games found", verify the team_id using:
python3 test_scripts/find_espn_team_id.py <sport> <league> <team_name>
Team IDs should be periodically verified, especially after:
- League expansion announcements
- Team relocations or rebranding
- When users report "no games found" for known active teams
"""
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional
from ..clients.espn_client import ESPNClient
from ..clients.sports_mappings import LEAGUE_MAPPINGS, SPORT_EMOJIS, TEAM_MAPPINGS
from ..clients.thesportsdb_client import TheSportsDBClient
from ..clients.worldcup_data import WorldCupData
from ..models import MeshMessage
from .base_command import BaseCommand
if TYPE_CHECKING:
from ..core import MeshCoreBot
class SportsCommand(BaseCommand):
"""Handles sports commands with ESPN API integration"""
# Plugin metadata
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "sports"
keywords = ['sports', 'score', 'scores']
description = "Get sports scores and schedules (usage: sports [team/league])"
category = "sports"
cooldown_seconds = 3 # 3 second cooldown per user to prevent API abuse
requires_internet = True # Requires internet access for ESPN API
# Documentation
short_description = "Get sports scores and schedules"
usage = "sports [team|league]"
examples = ["sports", "sports seahawks", "sports nfl"]
parameters = [
{"name": "team", "description": "Team name (e.g., seahawks, mariners)"},
{"name": "league", "description": "League code (nfl, mlb, nba, nhl, mls)"}
]
# ESPN client
espn_client: Optional[ESPNClient] = None
# TheSportsDB client for leagues not supported by ESPN
thesportsdb_client: Optional[TheSportsDBClient] = None
# Web-viewer settings schema (see modules/settings_schema.py)
settings_schema = [
{"key": "teams", "label": "Default teams", "type": "list",
"default": "seahawks,mariners,sounders,kraken",
"help": "Comma-separated team names (lowercase) shown when 'sports' is used with no args."},
{"key": "channels", "label": "Allowed channels", "type": "list",
"default": "",
"help": "Comma-separated channels to restrict to. Omit to use global monitor_channels."},
{"key": "channel_override", "label": "Channel team overrides", "type": "str",
"default": "",
"help": "channel=team,channel2=team2 — default team shortcut per channel."},
]
def __init__(self, bot: "MeshCoreBot"):
"""Initialize the sports command with API clients and configuration.
Args:
bot: The MeshCoreBot instance that owns this command.
"""
super().__init__(bot)
self.url_timeout = 10 # seconds
# Load enabled (standard enabled; sports_enabled legacy)
self.sports_enabled = self.get_config_value('Sports_Command', 'enabled', fallback=None, value_type='bool')
if self.sports_enabled is None:
self.sports_enabled = self.get_config_value('Sports_Command', 'sports_enabled', fallback=True, value_type='bool')
# Initialize API clients
self.espn_client = ESPNClient(logger=self.logger, timeout=self.url_timeout)
self.thesportsdb_client = TheSportsDBClient(logger=self.logger)
# Resolves World Cup nation names (e.g. `sports brazil`) to ESPN team ids
self.wc_data = WorldCupData(self.espn_client, 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
# Keep sports_channels for backward compatibility (used in execute() for channel-specific team defaults)
self.sports_channels = self.load_sports_channels()
self.channel_overrides = self.load_channel_overrides()
def load_default_teams(self) -> list[str]:
"""Load default teams from config"""
teams_str = self.get_config_value('Sports_Command', 'teams', fallback='seahawks,mariners,sounders,kraken', value_type='str')
return [team.strip().lower() for team in teams_str.split(',') if team.strip()]
def load_sports_channels(self) -> list[str]:
"""Load sports channels from config"""
channels_str = self.get_config_value('Sports_Command', 'channels', fallback='', value_type='str')
return [channel.strip() for channel in channels_str.split(',') if channel.strip()]
def load_channel_overrides(self) -> dict[str, str]:
"""Load channel overrides from config"""
overrides_str = self.get_config_value('Sports_Command', 'channel_override', fallback='', value_type='str')
overrides = {}
if overrides_str:
for override in overrides_str.split(','):
if '=' in override:
channel, team = override.strip().split('=', 1)
overrides[channel.strip()] = team.strip().lower()
return overrides
def matches_keyword(self, message: MeshMessage) -> bool:
"""Check if this command matches the message content - sports must be first word"""
if not self.keywords:
return False
content_lower = self.cleanup_message_for_matching(message)
# Split into words and check if first word matches any keyword
words = content_lower.split()
if not words:
return False
first_word = words[0]
return any(first_word == keyword.lower() for keyword in self.keywords)
def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool:
"""Check if this command can execute with the given message"""
if not self.sports_enabled:
return False
# Channel access and cooldown are now handled by BaseCommand.can_execute()
# Call parent can_execute() which includes channel checking and cooldown
return super().can_execute(message)
def get_help_text(self) -> str:
return self.translate('commands.sports.help')
async def get_default_teams_scores(self) -> str:
"""Get scores for default teams, sorted by game time"""
if not self.default_teams:
return self.translate('commands.sports.no_default_teams')
game_data = []
for team in self.default_teams:
try:
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)
if games:
game_data.extend(games)
except Exception as e:
self.logger.warning(f"Error fetching score for {team}: {e}")
if not game_data:
return self.translate('commands.sports.no_games_default')
# Sort by game time (earliest first)
game_data.sort(key=lambda x: x['timestamp'])
# Format responses with sport emojis
responses = []
for game in game_data:
sport_emoji = SPORT_EMOJIS.get(game['sport'], '🏆')
responses.append(f"{sport_emoji} {game['formatted']}")
# Join responses with newlines and ensure under 130 characters
result = "\n".join(responses)
if len(result) > 130:
# If still too long, truncate the last response
while len(result) > 130 and len(responses) > 1:
responses.pop()
result = "\n".join(responses)
if len(result) > 130:
result = result[:127] + "..."
return result
def get_league_info(self, league_name: str) -> Optional[dict[str, str]]:
"""Get league information for league queries"""
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"""
city_name_lower = city_name.lower()
# Define city mappings to team names
city_mappings = {
'seattle': ['seahawks', 'mariners', 'sounders', 'kraken', 'reign', 'storm', 'torrent'],
'chicago': ['bears', 'cubs', 'white sox', 'fire', 'sky', 'blackhawks'],
'new york': ['giants', 'jets', 'yankees', 'mets', 'knicks', 'nyc fc', 'red bulls', 'liberty', 'rangers', 'islanders'], # Add PWHL New York when team_id verified
'ny': ['giants', 'jets', 'yankees', 'mets', 'knicks', 'nyc fc', 'red bulls', 'liberty', 'rangers', 'islanders'], # Add PWHL New York when team_id verified
'los angeles': ['rams', 'dodgers', 'lakers', 'la galaxy', 'lafc', 'sparks'],
'la': ['rams', 'dodgers', 'lakers', 'la galaxy', 'lafc', 'sparks'],
'miami': ['dolphins', 'marlins', 'heat', 'inter miami'],
'boston': ['patriots', 'red sox', 'celtics', 'revolution', 'bruins'], # Add PWHL Boston when team_id verified
'philadelphia': ['eagles', 'phillies', '76ers', 'union'],
'atlanta': ['falcons', 'braves', 'hawks', 'atlanta united', 'dream'],
'houston': ['texans', 'astros', 'dynamo'],
'dallas': ['cowboys', 'rangers', 'stars', 'fc dallas', 'wings'],
'denver': ['broncos', 'rockies', 'rapids'],
'detroit': ['lions', 'tigers', 'pistons'],
'minnesota': ['vikings', 'twins', 'timberwolves', 'minnesota united', 'lynx', 'wild'], # Add PWHL Minnesota when team_id verified
'minneapolis': ['vikings', 'twins', 'timberwolves', 'minnesota united', 'lynx'], # Add PWHL Minnesota when team_id verified
'cleveland': ['browns', 'guardians', 'cavaliers'],
'cincinnati': ['bengals', 'reds', 'fc cincinnati'],
'pittsburgh': ['steelers', 'pirates', 'penguins'],
'baltimore': ['ravens', 'orioles'],
'tampa': ['buccaneers', 'rays', 'lightning'],
'tampa bay': ['buccaneers', 'rays', 'lightning'],
'kansas city': ['chiefs', 'royals', 'sporting kc'],
'kc': ['chiefs', 'royals', 'sporting kc'],
'washington': ['commanders', 'nationals', 'wizards', 'dc united', 'mystics'],
'dc': ['commanders', 'nationals', 'wizards', 'dc united', 'mystics'],
'phoenix': ['cardinals', 'diamondbacks', 'suns', 'mercury'],
'indiana': ['colts', 'pacers', 'fever'],
'indianapolis': ['colts', 'pacers', 'fever'],
'las vegas': ['raiders', 'aces', 'golden knights'],
'connecticut': ['sun'],
'arizona': ['cardinals', 'diamondbacks', 'coyotes'],
'golden state': ['warriors', 'valkyries'],
'san francisco': ['49ers', 'giants', 'warriors', 'earthquakes', 'valkyries'],
'sf': ['49ers', 'giants', 'warriors', 'earthquakes', 'valkyries'],
'san diego': ['chargers', 'padres', 'san diego fc'],
'sd': ['chargers', 'padres', 'san diego fc'],
'ind': ['colts', 'pacers'],
'nashville': ['titans', 'predators', 'nashville sc'],
'tennessee': ['titans', 'predators', 'nashville sc'],
'ten': ['titans', 'predators', 'nashville sc'],
'lv': ['raiders', 'golden knights'],
'louisville': ['racing'],
'carolina': ['panthers', 'hornets'],
'charlotte': ['panthers', 'hornets', 'charlotte fc'],
'new orleans': ['saints', 'pelicans'],
'no': ['saints', 'pelicans'],
'green bay': ['packers'],
'gb': ['packers'],
'buffalo': ['bills', 'sabres'],
'buf': ['bills', 'sabres'],
'milwaukee': ['bucks', 'brewers'],
'mil': ['bucks', 'brewers'],
'portland': ['trail blazers', 'timbers'],
'por': ['trail blazers', 'timbers'],
'pdx': ['trail blazers', 'timbers'],
'salt lake': ['jazz', 'real salt lake'],
'utah': ['jazz', 'real salt lake'],
'orlando': ['magic', 'orlando city'],
'orl': ['magic', 'orlando city'],
'toronto': ['raptors', 'blue jays', 'toronto fc', 'maple leafs'], # Add PWHL Toronto when team_id verified
'tor': ['raptors', 'blue jays', 'toronto fc', 'maple leafs'], # Add PWHL Toronto when team_id verified
'vancouver': ['canucks', 'whitecaps'],
'van': ['canucks', 'whitecaps'],
'montreal': ['canadiens', 'cf montreal'], # Add PWHL Montreal when team_id verified
'mtl': ['canadiens', 'cf montreal'], # Add PWHL Montreal when team_id verified
'calgary': ['flames'],
'edmonton': ['oilers'],
'winnipeg': ['jets'],
'ottawa': ['senators'], # Add PWHL Ottawa when team_id verified
'columbus': ['blue jackets', 'crew'],
'clb': ['blue jackets', 'crew'],
'st louis': ['blues', 'st louis city'],
'stl': ['blues', 'st louis city'],
'colorado': ['avalanche', 'rockies', 'rapids'],
'col': ['avalanche', 'rockies', 'rapids'],
'san jose': ['sharks', 'earthquakes'],
'sj': ['sharks', 'earthquakes'],
'anaheim': ['ducks', 'angels'],
'austin': ['austin fc'],
'atx': ['austin fc'],
}
# Get team names for this city
team_names = city_mappings.get(city_name_lower, [])
if not team_names:
return []
# Get team info for each team name
city_teams = []
for team_name in team_names:
team_info = TEAM_MAPPINGS.get(team_name)
if team_info:
city_teams.append(team_info)
return city_teams
async def get_city_scores(self, city_teams: list[dict[str, str]], city_name: str) -> str:
"""Get scores for all teams in a city"""
if not city_teams:
return self.translate('commands.sports.no_teams_city', city=city_name)
game_data = []
for team_info in city_teams:
try:
# Get all relevant games for this team (live, past within 8 days, upcoming within 6 weeks)
games = await self.fetch_team_games(team_info)
if games:
game_data.extend(games)
except Exception as e:
self.logger.warning(f"Error fetching score for {team_info}: {e}")
if not game_data:
return self.translate('commands.sports.no_games_city', city=city_name)
# Sort by game time (earliest first)
game_data.sort(key=lambda x: x['timestamp'])
# Format responses with sport emojis
responses = []
for game in game_data:
sport_emoji = SPORT_EMOJIS.get(game['sport'], '🏆')
responses.append(f"{sport_emoji} {game['formatted']}")
# Join responses with newlines and ensure under 130 characters
result = "\n".join(responses)
if len(result) > 130:
# If still too long, truncate the last response
while len(result) > 130 and len(responses) > 1:
responses.pop()
result = "\n".join(responses)
if len(result) > 130:
result = result[:127] + "..."
return result
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:
# 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'])
# Sort by game time (earliest first)
game_data.sort(key=lambda x: x['timestamp'])
# Format responses with sport emojis
responses = []
for game in game_data[:5]: # Limit to 5 games to keep under 130 chars
sport_emoji = SPORT_EMOJIS.get(game['sport'], '🏆')
responses.append(f"{sport_emoji} {game['formatted']}")
# Join responses with newlines and ensure under 130 characters
result = "\n".join(responses)
if len(result) > 130:
# If still too long, truncate the last response
while len(result) > 130 and len(responses) > 1:
responses.pop()
result = "\n".join(responses)
if len(result) > 130:
result = result[:127] + "..."
return result
except Exception as e:
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"""
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:
# Delegate to client
all_event_data = await self.thesportsdb_client.fetch_league_scores(
league_info['sport'], league_info['league'], league_id
)
if not all_event_data:
return self.translate('commands.sports.no_recent_scores', sport=league_info['sport'])
# Sort by timestamp
all_event_data.sort(key=lambda x: x['timestamp'])
# Format responses with sport emojis
sport_emoji = SPORT_EMOJIS.get(league_info['sport'], '🏆')
responses = []
current_length = 0
max_length = 130
for game in all_event_data:
game_str = f"{sport_emoji} {game['formatted']}"
test_length = current_length + len("\n") + len(game_str) if responses else len(game_str)
if test_length <= max_length:
responses.append(game_str)
current_length = test_length
else:
break
return "\n".join(responses)
except Exception as e:
self.logger.error(f"Error getting league scores from TheSportsDB: {e}")
return self.translate('commands.sports.error_fetching_league', sport=league_info['sport'])
async def resolve_world_cup_nation(self, team_name: str) -> Optional[dict[str, str]]:
"""Resolve a World Cup nation name to team_info, trying men's then women's."""
for wc_league in ('fifa.world', 'fifa.wwc'):
nation_info = await self.wc_data.resolve_nation(team_name, wc_league)
if nation_info:
return nation_info
return None
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")
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 (with World Cup nation fallback)
team_info = TEAM_MAPPINGS.get(team_name_clean)
if not team_info:
team_info = await self.resolve_world_cup_nation(team_name_clean)
if not team_info:
return self.translate('commands.sports.team_not_found', team=team_name_clean)
try:
schedule_info = await self.fetch_team_schedule_formatted(team_info)
if schedule_info:
return schedule_info
else:
return self.translate('commands.sports.no_games_team', team=team_name_clean)
except Exception as e:
self.logger.error(f"Error fetching schedule for {team_name_clean}: {e}")
return self.translate('commands.sports.error_fetching_team', team=team_name_clean)
# Check if this is a league query
league_info = self.get_league_info(team_name)
if league_info:
return await self.get_league_scores(league_info)
# Check if this is a city search that should return multiple teams
city_teams = self.get_city_teams(team_name)
if city_teams:
return await self.get_city_scores(city_teams, team_name)
# Otherwise, treat as single team query (with World Cup nation fallback)
team_info = TEAM_MAPPINGS.get(team_name)
if not team_info:
team_info = await self.resolve_world_cup_nation(team_name)
if not team_info:
return self.translate('commands.sports.team_not_found', team=team_name)
try:
score_info = await self.fetch_team_score(team_info)
if score_info:
# fetch_team_score already includes emojis, so return as-is
return score_info
else:
return self.translate('commands.sports.no_games_team', team=team_name)
except Exception as e:
self.logger.error(f"Error fetching score for {team_name}: {e}")
return self.translate('commands.sports.error_fetching_team', team=team_name)
async def fetch_team_score(self, team_info: dict[str, str]) -> Optional[str]:
"""Fetch score information for a team - returns current/next game plus past results"""
games = await self.fetch_team_games(team_info)
if not games:
return None
# Format games to fit within message limit (130 characters)
# Use 125 as a buffer to avoid cutting off mid-game
sport_emoji = SPORT_EMOJIS.get(team_info['sport'], '🏆')
formatted_games = []
current_length = 0
max_length = 125 # Leave buffer to avoid cutoff
for game in games:
# Ensure game['formatted'] doesn't already have an emoji
game_formatted = game['formatted'].strip()
# Remove emoji if it's at the start (some games might have it)
if game_formatted and game_formatted[0] in SPORT_EMOJIS.values():
game_formatted = game_formatted[1:].strip()
game_str = f"{sport_emoji} {game_formatted}"
# Check if adding this game would exceed limit
if formatted_games:
# Account for newline separator
test_length = current_length + len("\n") + len(game_str)
else:
test_length = len(game_str)
if test_length <= max_length:
formatted_games.append(game_str)
current_length = test_length
else:
# Can't fit more games - stop before exceeding limit
break
if not formatted_games:
# If even the first game doesn't fit, return it anyway (truncated)
game_formatted = games[0]['formatted'].strip()
if game_formatted and game_formatted[0] in SPORT_EMOJIS.values():
game_formatted = game_formatted[1:].strip()
return f"{sport_emoji} {game_formatted[:120]}"
return "\n".join(formatted_games)
async def fetch_team_games(self, team_info: dict[str, str]) -> list[dict]:
"""Fetch multiple games for a team: current/next game plus past results
Uses the team schedule endpoint which returns both past and upcoming games
in a single API call. Returns games sorted by relevance:
- Live games first
- 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 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.espn_client.fetch_live_event_data(
event_id, team_info['sport'], team_info['league']
)
if live_event_data:
# Update the game data with live scores
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:
all_games[game_index] = updated_game
except Exception as e:
self.logger.warning(f"Error fetching live data for event {event_id}: {e}")
# Sort by timestamp (negative for live games, then by actual timestamp)
# This prioritizes: live games > upcoming games > recent past games
all_games.sort(key=lambda x: x['timestamp'])
# Get current time for comparison
now = datetime.now(timezone.utc).timestamp()
# 8 days in seconds
eight_days_ago = now - (8 * 24 * 60 * 60)
# 6 weeks in seconds (6 * 7 * 24 * 60 * 60)
six_weeks_from_now = now + (6 * 7 * 24 * 60 * 60)
# Separate into categories
live_games = [g for g in all_games if g['timestamp'] < 0] # Negative timestamps = live
upcoming_games = []
past_games = []
# Categorize games with positive timestamps
for game in all_games:
if game['timestamp'] < 0:
continue # Already in live_games
game_event_ts = game.get('event_timestamp')
effective_ts = game_event_ts if game_event_ts is not None else game['timestamp']
if game['timestamp'] >= 9999999990 and game_event_ts is None:
# No real timestamp available, treat as past
past_games.append((effective_ts, game))
elif effective_ts is None:
past_games.append((effective_ts, game))
elif effective_ts > now:
# Future game - only include if within next 6 weeks
if effective_ts is not None and effective_ts <= six_weeks_from_now:
upcoming_games.append((effective_ts, game))
else:
# Past game - only include if within last 8 days
if effective_ts is not None and effective_ts >= eight_days_ago:
past_games.append((effective_ts, game))
# Sort upcoming games by soonest first, past games by most recent first
upcoming_games.sort(key=lambda x: x[0] if x[0] is not None else float('inf'))
past_games.sort(key=lambda x: x[0] if x[0] is not None else -float('inf'), reverse=True)
# Build result with new priority:
# 1. Live games (if any)
# 2. Last completed game (if within last 8 days)
# 3. Next scheduled game (if known and within 6 weeks)
result = []
# Add live games (if any)
if live_games:
result.extend(live_games)
# Add last completed game (if within last 8 days)
if past_games:
result.append(past_games[0][1]) # Most recent past game
# Add next scheduled game (if known and within 6 weeks)
if upcoming_games:
result.append(upcoming_games[0][1])
return result
except Exception as e:
self.logger.error(f"Error fetching team games: {e}")
return []
async def fetch_team_games_thesportsdb(self, team_info: dict[str, str]) -> list[dict]:
"""Fetch team games from TheSportsDB API via client"""
if not self.thesportsdb_client:
self.logger.error("TheSportsDB client not initialized")
return []
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
Uses the team schedule endpoint which returns both past and upcoming games
in a single API call, eliminating the need for multiple scoreboard requests.
Returns only the most relevant game (for backward compatibility).
"""
games = await self.fetch_team_games(team_info)
return games[0] if games else None
async def fetch_team_schedule(self, team_info: dict[str, str]) -> list[dict]:
"""Fetch upcoming scheduled games for a team
Returns as many upcoming games as available from the schedule endpoint.
Used for 'sports <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:
# 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
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']
# 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
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 via client"""
if not self.thesportsdb_client:
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
Returns formatted schedule with as many games as fit in 130 characters.
"""
games = await self.fetch_team_schedule(team_info)
if not games:
return None
# Format games to fit within message limit (130 characters)
# Use 125 as a buffer to avoid cutting off mid-game
sport_emoji = SPORT_EMOJIS.get(team_info['sport'], '🏆')
formatted_games = []
current_length = 0
max_length = 125 # Leave buffer to avoid cutoff
for game in games:
# Ensure game['formatted'] doesn't already have an emoji
game_formatted = game['formatted'].strip()
# Remove emoji if it's at the start (some games might have it)
if game_formatted and game_formatted[0] in SPORT_EMOJIS.values():
game_formatted = game_formatted[1:].strip()
game_str = f"{sport_emoji} {game_formatted}"
# Check if adding this game would exceed limit
if formatted_games:
# Account for newline separator
test_length = current_length + len("\n") + len(game_str)
else:
test_length = len(game_str)
if test_length <= max_length:
formatted_games.append(game_str)
current_length = test_length
else:
# Can't fit more games - stop before exceeding limit
break
if not formatted_games:
# If even the first game doesn't fit, return it anyway (truncated)
game_formatted = games[0]['formatted'].strip()
if game_formatted and game_formatted[0] in SPORT_EMOJIS.values():
game_formatted = game_formatted[1:].strip()
return f"{sport_emoji} {game_formatted[:120]}"
return "\n".join(formatted_games)
async def execute(self, message: MeshMessage) -> bool:
"""Main entry point for command execution"""
try:
# Record execution for this user (handles cooldown)
self.record_execution(message.sender_id)
content = message.content.strip()
if content.startswith('!'):
content = content[1:].strip()
# 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:
response = await self.get_default_teams_scores()
return await self.send_response(message, response)
query = parts[1].strip()
# 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)
# 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)
# 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}.")
return await self.send_response(message, scores)
except Exception as e:
self.logger.error(f"Error in sports execute: {e}")
return await self.send_response(message, "Error processing sports command.")