"""Persistence of pending reaction-request mappings across bot restarts. The bot uses reaction messages (✅ / ❌) on DMs to let a coach approve or reject a one-on-one request without visiting the site. The mapping from Discord message_id → {request_type, primary_key} is stored in a JSON file so that a bot restart does not orphan reactions that were already sent. """ import json import logging import os from datetime import UTC, datetime from app.discord.constants import PENDING_MAX_AGE_DAYS from app.discord.queue import enqueue _logger = logging.getLogger(__name__) #: File for persisting pending requests across bot restarts. PENDING_FILE = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'discord_pending.json', ) def load_pending() -> dict: """Load pending requests from the JSON file. Returns: dict mapping message_id (int) → entry dict. """ if not os.path.exists(PENDING_FILE): return {} try: with open(PENDING_FILE, encoding='utf-8') as f: entries = json.load(f) except (json.JSONDecodeError, OSError): _logger.warning('Could not read pending file, starting empty.', exc_info=True) return {} if not isinstance(entries, dict): return {} cleaned = drop_expired(entries) if len(cleaned) < len(entries): save_pending(cleaned) return cleaned def drop_expired(entries: dict) -> dict: """Remove entries whose creation timestamp is too old. Args: entries: dict mapping message_id → entry. Returns: Filtered dict. """ now = datetime.now(UTC).timestamp() cutoff = now - PENDING_MAX_AGE_DAYS * 86400 return {mid: e for mid, e in entries.items() if e.get('created_at', 0) >= cutoff} def save_pending(entries: dict) -> None: """Persist pending entries to the JSON file.""" try: with open(PENDING_FILE, 'w', encoding='utf-8') as f: json.dump(entries, f, indent=2) except OSError: _logger.exception('Could not write pending file') def forget_pending(entries: dict, message_id: int) -> dict: """Remove one message from the pending map and persist. Args: entries: The current pending map. message_id: The message whose entry to remove. Returns: Updated dict (already persisted). """ entries.pop(str(message_id), None) save_pending(entries) return entries def purge_pending_for_participants(participant_ids: list[int]) -> None: """Queue a purge of stale participant pending entries. Called after a match edit deletes participants. The bot thread removes matching ``schedule_addition`` pending entries so that a reaction on an old message cannot produce a false confirmation. Args: participant_ids: MatchParticipant primary keys that no longer exist. """ if not participant_ids: return enqueue( 'purge_pending', {'participant_ids': participant_ids}, description=f'purge of {len(participant_ids)} stale participant records', )