Files
agessaman fc63039a48 fix: address Codex review of tonight's work
Correctness:

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

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

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

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

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

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

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

Web viewer:

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

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

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

299 lines
13 KiB
Python

#!/usr/bin/env python3
"""
World Cup command for the MeshCore Bot.
Provides FIFA World Cup coverage (men's fifa.world / women's fifa.wwc) via the ESPN API,
but only while a tournament is actually in progress (determined from the ESPN tournament
calendar). Supports:
wc / worldcup -> today's scores, including live results
wc group <X> -> standings for group X
wc <nation> -> that nation's matches in the current tournament
World Cup data is also reachable year-round through the `sports` command
(e.g. `sports fifa`, `sports brazil`); this command is the season-gated, purpose-built
front end with live scores, standings, and nation lookups.
"""
from typing import TYPE_CHECKING
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:
from ..core import MeshCoreBot
SOCCER_EMOJI = SPORT_EMOJIS.get("soccer", "⚽")
FINAL_STATUSES = {"STATUS_FINAL", "STATUS_FULL_TIME", "STATUS_FINAL_PEN", "STATUS_POSTPONED"}
class WorldCupCommand(BaseCommand):
"""Season-gated FIFA World Cup scores, standings, and nation lookups."""
# Plugin metadata
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "worldcup"
keywords = ["wc", "worldcup"]
description = "FIFA World Cup scores, standings, and nation results (in-season only)"
category = "sports"
cooldown_seconds = 3
requires_internet = True
# Documentation
short_description = "FIFA World Cup scores (in-season)"
usage = "wc [group <X> | <nation>]"
examples = ["wc", "wc group a", "wc brazil"]
parameters = [
{"name": "group <X>", "description": "Standings for a group (e.g. group a)"},
{"name": "nation", "description": "A nation's matches (e.g. brazil, usa)"},
]
def __init__(self, bot: "MeshCoreBot"):
"""Initialize the World Cup command with an ESPN client and data helper."""
super().__init__(bot)
self.url_timeout = self.get_config_value("Worldcup_Command", "api_timeout", fallback=10, value_type="int")
# Standard enabled flag
self.worldcup_enabled = self.get_config_value("Worldcup_Command", "enabled", fallback=True, value_type="bool")
cache_ttl_minutes = self.get_config_value("Worldcup_Command", "cache_ttl_minutes", fallback=360, value_type="int")
self.espn_client: ESPNClient = ESPNClient(logger=self.logger, timeout=self.url_timeout)
self.wc_data = WorldCupData(self.espn_client, logger=self.logger, cache_ttl=cache_ttl_minutes * 60)
def matches_keyword(self, message: MeshMessage) -> bool:
"""Match only when a keyword is the first word (mirrors SportsCommand)."""
if not self.keywords:
return False
content_lower = self.cleanup_message_for_matching(message)
words = content_lower.split()
if not words:
return False
return any(words[0] == keyword.lower() for keyword in self.keywords)
def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool:
"""Gate on the enabled flag; season gating happens in execute() (needs network)."""
if not self.worldcup_enabled:
return False
return super().can_execute(message)
def get_help_text(self) -> str:
return self.translate("commands.worldcup.help")
# ------------------------------------------------------------------ helpers
def _pack_lines(self, lines: list[str], max_len: int, max_chunks: int = 3) -> list[str]:
"""Greedily pack lines into up to max_chunks messages within max_len UTF-8 bytes."""
chunks: list[str] = []
current = ""
for line in lines:
candidate = line if not current else f"{current}\n{line}"
if len(candidate.encode("utf-8")) <= max_len:
current = candidate
continue
# Line doesn't fit in current chunk; flush and start a new one
if current:
chunks.append(current)
if len(chunks) >= max_chunks:
return chunks
current = ""
if len(line.encode("utf-8")) <= max_len:
current = line
else:
# Single oversized line: hard-truncate on a byte boundary
truncated = line.encode("utf-8")[:max_len].decode("utf-8", "ignore")
chunks.append(truncated)
if len(chunks) >= max_chunks:
return chunks
if current and len(chunks) < max_chunks:
chunks.append(current)
return chunks[:max_chunks]
@staticmethod
def _today_sort_key(game: dict) -> tuple[int, float]:
"""Order today's games: live first, then completed results, then upcoming."""
status = game.get("status", "")
ts = game.get("event_timestamp") or 0
if game.get("timestamp", 0) < 0:
return (0, ts) # live / halftime
if status in ("STATUS_FINAL", "STATUS_FULL_TIME", "STATUS_FINAL_PEN", "STATUS_POSTPONED"):
return (1, ts) # completed results
return (2, ts) # scheduled later
@staticmethod
def _dedupe_games(games: list[dict]) -> list[dict]:
"""Drop duplicate events (the team-schedule endpoint can repeat today's game)."""
seen = set()
unique = []
for game in games:
key = game.get("id") or game.get("formatted")
if key in seen:
continue
seen.add(key)
unique.append(game)
return unique
@staticmethod
def _select_nation_games(games: list[dict], in_group_stage: bool) -> list[dict]:
"""Pick which of a nation's matches to show so the reply fits one message.
During the group stage, show all of the nation's (known) group matches. Afterward,
show just the last result (or a live game) plus the next scheduled fixture.
"""
if in_group_stage:
return games
live = [g for g in games if g.get("timestamp", 0) < 0]
completed = sorted(
[g for g in games if g.get("status") in FINAL_STATUSES],
key=lambda g: g.get("event_timestamp") or 0,
reverse=True,
)
upcoming = sorted(
[g for g in games if g.get("status") == "STATUS_SCHEDULED"],
key=lambda g: g.get("event_timestamp") or 0,
)
selected: list[dict] = []
if live:
selected.extend(live)
elif completed:
selected.append(completed[0])
if upcoming:
selected.append(upcoming[0])
return selected
def _format_games(self, games: list[dict]) -> list[str]:
"""Format parsed game dicts into '⚽ ...' lines (emoji de-duplicated)."""
lines = []
for game in games:
formatted = game.get("formatted", "").strip()
if formatted and formatted[0] in SPORT_EMOJIS.values():
formatted = formatted[1:].strip()
lines.append(f"{SOCCER_EMOJI} {formatted}")
return lines
async def _refresh_live(self, games: list[dict], league: str) -> list[dict]:
"""Refresh any live games with up-to-date scoreboard data (for stale schedules)."""
for i, game in enumerate(games):
if game.get("timestamp", 0) < 0 and game.get("id"):
try:
live = await self.espn_client.fetch_live_event_data(game["id"], "soccer", league)
if live:
updated = self.espn_client.parse_game_event_with_timestamp(live, "", "soccer", league)
if updated:
games[i] = updated
except Exception as e:
self.logger.warning(f"World Cup live refresh failed for {game.get('id')}: {e}")
return games
# ----------------------------------------------------------------- handlers
async def _handle_today(self, message: MeshMessage, league: str) -> bool:
"""Send today's scores (live first), chunked across up to 3 messages."""
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"))
games.sort(key=self._today_sort_key)
lines = self._format_games(games)
chunks = self._pack_lines(lines, self.get_max_message_length(message), max_chunks=3)
return await self.send_response_chunked(message, chunks)
async def _handle_group(self, message: MeshMessage, league: str, letter: str) -> bool:
"""Send standings for a single group, or list groups if no letter given."""
groups = await self.espn_client.fetch_standings("soccer", league)
if not groups:
return await self.send_response(message, self.translate("commands.worldcup.no_games_today"))
if not letter:
available = ", ".join(
g["group_name"].replace("Group ", "").strip() for g in groups if g.get("group_name")
)
return await self.send_response(message, self.translate("commands.worldcup.groups_list", groups=available))
letter = letter.strip().lower()
match = next(
(g for g in groups if g.get("group_name", "").strip().lower().endswith(f" {letter}")),
None,
)
if not match:
return await self.send_response(message, self.translate("commands.worldcup.group_not_found", group=letter.upper()))
lines = [f"{SOCCER_EMOJI} {match['group_name']}"]
for e in match["entries"]:
lines.append(f"{e['rank']}.{e['abbr']} {e['pts']}p {e['gd']:+d} ({e['w']}-{e['d']}-{e['l']})")
chunks = self._pack_lines(lines, self.get_max_message_length(message), max_chunks=2)
return await self.send_response_chunked(message, chunks)
async def _handle_nation(self, message: MeshMessage, active: dict, name: str) -> bool:
"""Send a nation's matches in the current tournament (results + upcoming fixtures)."""
league = active["league"]
team_info = await self.wc_data.resolve_nation(name, league)
if not team_info:
return await self.send_response(message, self.translate("commands.worldcup.nation_not_found", nation=name))
# Prefer the dated scoreboard range: it includes known future fixtures, which the
# team /schedule endpoint omits for tournament national teams. Fall back to the
# schedule endpoint if the tournament window is unavailable.
start, end = active.get("start_date"), active.get("end_date")
if start and end:
games = await self.espn_client.fetch_team_fixtures("soccer", league, team_info["team_id"], start, end)
else:
games = await self.espn_client.fetch_team_schedule("soccer", league, team_info["team_id"])
if not games:
return await self.send_response(message, self.translate("commands.worldcup.no_games_nation", nation=name))
games = self._dedupe_games(games)
games = await self._refresh_live(games, league)
games = self._select_nation_games(games, active.get("in_group_stage", False))
games.sort(key=self._today_sort_key)
lines = self._format_games(games)
chunks = self._pack_lines(lines, self.get_max_message_length(message), max_chunks=1)
return await self.send_response_chunked(message, chunks)
async def execute(self, message: MeshMessage) -> bool:
"""Main entry point: gate on season, then dispatch on subcommand."""
try:
self.record_execution(message.sender_id)
content = message.content.strip()
if content.startswith("!"):
content = content[1:].strip()
# Drop the leading keyword, keep the remainder as the argument string
parts = content.split(" ", 1)
args = parts[1].strip() if len(parts) > 1 else ""
active = await self.wc_data.get_active_tournament()
if not active:
return await self.send_response(message, self.translate("commands.worldcup.not_in_season"))
league = active["league"]
if not args:
return await self._handle_today(message, league)
lowered = args.lower()
if lowered == "group" or lowered == "groups":
return await self._handle_group(message, league, "")
if lowered.startswith("group "):
return await self._handle_group(message, league, args[6:].strip())
return await self._handle_nation(message, active, args)
except Exception as e:
self.logger.error(f"Error in worldcup execute: {e}")
return await self.send_response(message, self.translate("commands.worldcup.error_fetching"))