diff --git a/modules/clients/espn_client.py b/modules/clients/espn_client.py index d7143e6..a6d78a2 100644 --- a/modules/clients/espn_client.py +++ b/modules/clients/espn_client.py @@ -33,6 +33,37 @@ class ESPNClient: self.session = aiohttp.ClientSession(timeout=self.timeout) return self.session + @staticmethod + def _scoreboard_url( + sport: str, + league: str, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + cache_bust: bool = False, + ) -> str: + url = f"{ESPNClient.BASE_URL}/{sport}/{league}/scoreboard" + params: list[str] = [] + if start_date and end_date: + params.append(f"dates={start_date}-{end_date}") + if cache_bust: + params.append(f"_={int(time.time() * 1000)}") + if params: + url += "?" + "&".join(params) + return url + + @staticmethod + def _event_timestamp(event: dict) -> Optional[float]: + date_str = event.get('date', '') + if not date_str: + return None + try: + dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + except (TypeError, ValueError): + return None + 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" @@ -53,7 +84,13 @@ class ESPNClient: self.logger.error(f"ESPN fetch_scoreboard error for {sport}/{league}: {e}") return [] - async def fetch_scoreboard_with_calendar(self, sport: str, league: str) -> Optional[dict]: + async def fetch_scoreboard_with_calendar( + self, + sport: str, + league: str, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + ) -> Optional[dict]: """Fetch scoreboard plus tournament calendar/league metadata. Returns a dict with: @@ -64,8 +101,10 @@ class ESPNClient: Used by the World Cup command to determine whether a tournament is in season and to resolve nation names when standings are unavailable. Returns None on error. + + Optional start_date/end_date are YYYYMMDD strings for the dated scoreboard endpoint. """ - url = f"{self.BASE_URL}/{sport}/{league}/scoreboard" + url = self._scoreboard_url(sport, league, start_date, end_date) try: session = await self._get_session() async with session.get(url) as response: @@ -153,11 +192,18 @@ class ESPNClient: except (TypeError, ValueError): return 0 - async def fetch_match_states(self, sport: str, league: str, cache_bust: bool = False) -> list[dict]: + async def fetch_match_states( + self, + sport: str, + league: str, + cache_bust: bool = False, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + ) -> list[dict]: """Return current per-match live state for the scoreboard (today's matches). Each item: {id, home_id, away_id, home_name, away_name, home_score, away_score, - status, clock, home_pen, away_pen, goals, cards}. Names are full team display + status, clock, home_pen, away_pen, goals, cards, event_timestamp}. Names are full team display names. Penalty fields are None unless a shootout score is present. ``goals`` is the chronological list of scoring plays, each {clock, scorer, team_id, own_goal, penalty, kind} (kind is 'header'/'volley'/''; penalty shootout kicks excluded). @@ -167,10 +213,10 @@ class ESPNClient: cache_bust appends a unique query param to bypass ESPN's edge cache, used when a fastcast push signals a change so the REST snapshot reflects it immediately. + + Optional start_date/end_date are YYYYMMDD strings for the dated scoreboard endpoint. """ - url = f"{self.BASE_URL}/{sport}/{league}/scoreboard" - if cache_bust: - url += f"?_={int(time.time() * 1000)}" + url = self._scoreboard_url(sport, league, start_date, end_date, cache_bust=cache_bust) try: session = await self._get_session() async with session.get(url) as response: @@ -232,6 +278,7 @@ class ESPNClient: 'goals': goals, 'cards': red_cards, 'yellows': yellow_cards, + 'event_timestamp': self._event_timestamp(event), }) except Exception as e: self.logger.warning(f"ESPN fetch_match_states: skipping malformed event {event.get('id')}: {e}") diff --git a/modules/commands/worldcup_command.py b/modules/commands/worldcup_command.py index a8208ed..44b9627 100644 --- a/modules/commands/worldcup_command.py +++ b/modules/commands/worldcup_command.py @@ -21,6 +21,7 @@ from ..clients.espn_client import ESPNClient from ..clients.sports_mappings import SPORT_EMOJIS from ..clients.worldcup_data import WorldCupData from ..models import MeshMessage +from ..utils import espn_dates_for_local_day, filter_events_local_day, get_config_timezone from .base_command import BaseCommand if TYPE_CHECKING: @@ -192,9 +193,13 @@ class WorldCupCommand(BaseCommand): async def _handle_today(self, message: MeshMessage, league: str) -> bool: """Send today's scores (live first), chunked across up to 3 messages.""" - # The scoreboard endpoint already carries live scores, so no refresh is needed. - data = await self.espn_client.fetch_scoreboard_with_calendar("soccer", league) + local_tz, _ = get_config_timezone(self.bot.config, self.logger) + start_date, end_date, local_start_ts, local_end_ts = espn_dates_for_local_day(local_tz) + data = await self.espn_client.fetch_scoreboard_with_calendar( + "soccer", league, start_date=start_date, end_date=end_date, + ) games = data.get("events", []) if data else [] + games = filter_events_local_day(games, local_start_ts, local_end_ts) if not games: return await self.send_response(message, self.translate("commands.worldcup.no_games_today")) diff --git a/modules/service_plugins/worldcup_service.py b/modules/service_plugins/worldcup_service.py index ab48a84..4998550 100644 --- a/modules/service_plugins/worldcup_service.py +++ b/modules/service_plugins/worldcup_service.py @@ -21,6 +21,7 @@ from typing import Any, Optional from ..clients.espn_client import ESPNClient from ..clients.worldcup_data import WorldCupData from ..clients.worldcup_fastcast import WorldCupFastcastClient +from ..utils import espn_dates_for_local_day, filter_events_local_day, get_config_timezone from .base_service import BaseServicePlugin LIVE_STATUSES = {"STATUS_IN_PROGRESS", "STATUS_FIRST_HALF", "STATUS_SECOND_HALF", "STATUS_END_PERIOD"} @@ -191,7 +192,15 @@ class WorldCupLiveService(BaseServicePlugin): in_group = active.get("in_group_stage", False) stage_label = active.get("stage_label", "") - matches = await self.espn_client.fetch_match_states("soccer", league, cache_bust=self.use_fastcast) + local_tz, _ = get_config_timezone(self.bot.config, self.logger) + start_date, end_date, local_start_ts, local_end_ts = espn_dates_for_local_day(local_tz) + matches = await self.espn_client.fetch_match_states( + "soccer", league, + cache_bust=self.use_fastcast, + start_date=start_date, + end_date=end_date, + ) + matches = filter_events_local_day(matches, local_start_ts, local_end_ts) if not matches: return self.poll_interval_seconds diff --git a/modules/utils.py b/modules/utils.py index 3e73507..21951a9 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -10,7 +10,7 @@ import re import socket import urllib.error import urllib.request -from datetime import datetime +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Optional, Union @@ -57,6 +57,57 @@ def get_config_timezone(config: Any, logger: Optional[Any] = None) -> tuple[Any, return (tz, "UTC") +def espn_dates_for_local_day( + local_tz: Any, + now: Optional[datetime] = None, +) -> tuple[str, str, float, float]: + """Return ESPN scoreboard date range and local-day bounds for filtering events. + + ESPN buckets scoreboard events by UTC calendar date. A single local calendar day + can span two UTC dates (e.g. 9pm PT is the next UTC day). This returns the + min/max YYYYMMDD strings to query, plus local midnight timestamps for filtering. + + Returns: + (start_yyyymmdd, end_yyyymmdd, local_start_ts, local_end_ts) + """ + if now is None: + now = datetime.now(local_tz) + elif now.tzinfo is None: + if hasattr(local_tz, 'localize'): + now = local_tz.localize(now) + else: + now = now.replace(tzinfo=local_tz) + else: + now = now.astimezone(local_tz) + + if hasattr(local_tz, 'localize'): + local_start = local_tz.localize(datetime(now.year, now.month, now.day)) + else: + local_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + local_end = local_start + timedelta(days=1) + + utc_dates = { + local_start.astimezone(timezone.utc).strftime("%Y%m%d"), + (local_end - timedelta(seconds=1)).astimezone(timezone.utc).strftime("%Y%m%d"), + } + return min(utc_dates), max(utc_dates), local_start.timestamp(), local_end.timestamp() + + +def filter_events_local_day( + events: list[dict], + local_start_ts: float, + local_end_ts: float, + ts_key: str = "event_timestamp", +) -> list[dict]: + """Keep events whose kickoff falls in [local_start, local_end); keep missing timestamps.""" + filtered: list[dict] = [] + for event in events: + ts = event.get(ts_key) + if ts is None or (local_start_ts <= ts < local_end_ts): + filtered.append(event) + return filtered + + def format_temperature_high_low( config: Any, high: Optional[Union[int, float]], diff --git a/tests/test_espn_dates.py b/tests/test_espn_dates.py new file mode 100644 index 0000000..011b286 --- /dev/null +++ b/tests/test_espn_dates.py @@ -0,0 +1,48 @@ +"""Tests for ESPN local-day date span helpers used by World Cup features.""" + +from datetime import datetime, timezone + +import pytz + +from modules.utils import espn_dates_for_local_day, filter_events_local_day + + +class TestEspnDatesForLocalDay: + def test_june_20_pt_spans_two_utc_days(self): + tz = pytz.timezone("America/Los_Angeles") + # 9:30pm PT on June 20 — evening match in progress + now = tz.localize(datetime(2026, 6, 20, 21, 30)) + start, end, _, _ = espn_dates_for_local_day(tz, now) + assert start == "20260620" + assert end == "20260621" + + def test_utc_midday_single_utc_day(self): + tz = timezone.utc + now = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc) + start, end, _, _ = espn_dates_for_local_day(tz, now) + assert start == end == "20260620" + + +class TestFilterEventsLocalDay: + def test_keeps_afternoon_and_evening_drops_next_local_day(self): + tz = pytz.timezone("America/Los_Angeles") + now = tz.localize(datetime(2026, 6, 20, 21, 30)) + _, _, local_start_ts, local_end_ts = espn_dates_for_local_day(tz, now) + + afternoon_ts = datetime(2026, 6, 20, 21, 0, tzinfo=timezone.utc).timestamp() # 2pm PT + evening_ts = datetime(2026, 6, 21, 4, 0, tzinfo=timezone.utc).timestamp() # 9pm PT + next_day_ts = datetime(2026, 6, 21, 21, 0, tzinfo=timezone.utc).timestamp() # 2pm PT June 21 + + games = [ + {"id": "afternoon", "event_timestamp": afternoon_ts}, + {"id": "evening", "event_timestamp": evening_ts}, + {"id": "tomorrow", "event_timestamp": next_day_ts}, + ] + filtered = filter_events_local_day(games, local_start_ts, local_end_ts) + assert [g["id"] for g in filtered] == ["afternoon", "evening"] + + def test_keeps_events_missing_timestamp(self): + filtered = filter_events_local_day( + [{"id": "unknown"}], local_start_ts=0, local_end_ts=9999999999, + ) + assert filtered == [{"id": "unknown"}] diff --git a/tests/test_espn_match_states.py b/tests/test_espn_match_states.py index fb504ee..75b4525 100644 --- a/tests/test_espn_match_states.py +++ b/tests/test_espn_match_states.py @@ -30,8 +30,10 @@ class _FakeSession: def __init__(self, payload): self._payload = payload self.closed = False + self.last_url = None def get(self, url): + self.last_url = url return _FakeResp(self._payload) @@ -129,3 +131,21 @@ class TestFetchMatchStatesResilience: "type": {"text": "Goal - Header"}, "athletesInvolved": [{"displayName": "Gakpo"}]} states = await _client({"events": [_event("1", 1, 0, details=[det])]}).fetch_match_states("soccer", "fifa.world") assert states[0]["goals"][0]["kind"] == "header" + + async def test_fetch_match_states_uses_dates_param(self): + session = _FakeSession({"events": []}) + client = ESPNClient(logger=Mock(), session=session) + await client.fetch_match_states( + "soccer", "fifa.world", start_date="20260620", end_date="20260621", + ) + assert session.last_url is not None + assert "dates=20260620-20260621" in session.last_url + + async def test_fetch_match_states_dates_and_cache_bust(self): + session = _FakeSession({"events": []}) + client = ESPNClient(logger=Mock(), session=session) + await client.fetch_match_states( + "soccer", "fifa.world", cache_bust=True, start_date="20260620", end_date="20260621", + ) + assert "dates=20260620-20260621" in session.last_url + assert "_=" in session.last_url diff --git a/tests/test_worldcup_command.py b/tests/test_worldcup_command.py index c76d8ff..79766e5 100644 --- a/tests/test_worldcup_command.py +++ b/tests/test_worldcup_command.py @@ -4,9 +4,12 @@ import configparser from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, Mock +import pytz + from modules.clients.espn_client import ESPNClient from modules.clients.worldcup_data import WorldCupData from modules.commands.worldcup_command import WorldCupCommand +from modules.utils import espn_dates_for_local_day from tests.conftest import mock_message @@ -16,6 +19,7 @@ def _make_bot(): config = configparser.ConfigParser() config.add_section("Bot") config.set("Bot", "bot_name", "TestBot") + config.set("Bot", "timezone", "America/Los_Angeles") config.add_section("Channels") config.set("Channels", "monitor_channels", "general") config.set("Channels", "respond_to_dms", "true") @@ -171,13 +175,21 @@ class TestDispatch: async def test_today_no_args(self): cmd = _make_command() _patch_active(cmd) + tz = pytz.timezone("America/Los_Angeles") + _, _, local_start_ts, _ = espn_dates_for_local_day(tz) + ft_ts = local_start_ts + 14 * 3600 + live_ts = local_start_ts + 21 * 3600 cmd.espn_client.fetch_scoreboard_with_calendar = AsyncMock( return_value={"events": [ - {"formatted": "@GER 7-1 CUW (FT)", "timestamp": 9999999998, "status": "STATUS_FULL_TIME", "event_timestamp": 100}, - {"formatted": "@NED 2-2 JPN (45')", "timestamp": -1, "status": "STATUS_IN_PROGRESS", "event_timestamp": 200}, + {"formatted": "@GER 7-1 CUW (FT)", "timestamp": 9999999998, "status": "STATUS_FULL_TIME", "event_timestamp": ft_ts}, + {"formatted": "@NED 2-2 JPN (45')", "timestamp": -1, "status": "STATUS_IN_PROGRESS", "event_timestamp": live_ts}, ]} ) await cmd.execute(mock_message("wc")) + kwargs = cmd.espn_client.fetch_scoreboard_with_calendar.await_args.kwargs + assert kwargs["start_date"] + assert kwargs["end_date"] + assert kwargs["start_date"] <= kwargs["end_date"] chunks = cmd.bot.command_manager.send_response_chunked.await_args.args[1] # Live game should be ordered before the completed result assert chunks[0].splitlines()[0].endswith("(45')")