mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-09-01 16:48:26 +00:00
feat(worldcup): enhance scoreboard fetching with date range support
- Added new utility functions `espn_dates_for_local_day` and `filter_events_local_day` to handle local timezone date calculations and event filtering. - Updated `ESPNClient` to support optional date parameters in `fetch_scoreboard_with_calendar` and `fetch_match_states` methods, allowing for more precise data retrieval. - Modified `WorldCupCommand` and `WorldCupLiveService` to utilize the new date range functionality, ensuring accurate event reporting based on local time. - Implemented unit tests to verify the correct behavior of date handling and event filtering in various scenarios.
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+52
-1
@@ -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]],
|
||||
|
||||
@@ -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"}]
|
||||
@@ -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
|
||||
|
||||
@@ -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')")
|
||||
|
||||
Reference in New Issue
Block a user