feat: Introduce ESPN and TheSportsDB clients and enhance command code quality with type hints and docstrings.

This commit is contained in:
agessaman
2026-01-01 21:55:48 -08:00
parent a32fe0dcfd
commit 52d1dac3db
17 changed files with 1931 additions and 2247 deletions
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env python3
"""
API Clients for MeshCore Bot
Encapsulates external API interactions
"""
+320
View File
@@ -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)
+802
View File
@@ -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'
]
+321
View File
@@ -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
+9
View File
@@ -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.
+133 -85
View File
@@ -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."
+36 -4
View File
@@ -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
+66 -17
View File
@@ -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')
+1
View File
@@ -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
+1
View File
@@ -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.
+57 -12
View File
@@ -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')
+46 -8
View File
@@ -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
+1
View File
@@ -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)
+1
View File
@@ -16,6 +16,7 @@ class SatpassCommand(BaseCommand):
keywords = ['satpass']
description = "Get satellite pass info: satpass <NORAD_number_or_shortcut> [visual]"
category = "solar"
requires_internet = True # Requires internet access for N2YO API
# Common satellite shortcuts
SATELLITE_SHORTCUTS = {
+2 -1
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -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: