""" tasks.py Scheduler definitions using discord.ext.tasks loops. Thin wrappers that invoke the corresponding execute_* functions from task_executors.py on configured intervals. """ # Standard Library Imports import asyncio import json import logging import shutil from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, Optional # Third-Party Library Imports from discord.ext import tasks # Local Module Imports from . import lux_apis from .autologging import handle_ws_replays, handle_gob_message from .health import record_task_run, write_heartbeat from .meta_manager import process_all_players, sync_all_guild_metas from .task_executors import ( execute_ldb_alarm_task, execute_points_alarm_task, execute_leave_alarm_task, execute_squadron_stats_tracker, execute_update_squadrons_db_task, execute_sync_squadron_members_bulk, execute_sync_squadron_members_points_loop, execute_cleanup_stale_squadrons, execute_weekly_br_report_task, init_squadrons_points_table, cleanup_replays, ) from .utils import ( get_bot, STACKS_DIR, refresh_entitled_guilds, SQB_STATS_TRACKER_WINDOWS, SQB_BOUNDARY_TIMES, ) async def _record(task_name: str, success: bool, error: str = ""): """Record task execution for health dashboard.""" await record_task_run(task_name, success, error) # ============================================================================ # LEADERBOARD ALARM TASK # ============================================================================ @tasks.loop(seconds=60) async def ldb_alarm_task(): """Checks time and triggers leaderboard alarm at 22:35 and 7:35 UTC.""" now_utc = datetime.now(timezone.utc).time() if (now_utc.hour, now_utc.minute) in [(22, 35), (7, 35)]: try: await execute_ldb_alarm_task() await _record("ldb_alarm", True) except Exception as e: await _record("ldb_alarm", False, str(e)) raise @ldb_alarm_task.before_loop async def before_ldb_alarm_task(): await get_bot().wait_until_ready() # ============================================================================ # SQUADRON STATS TRACKER TASKS # ============================================================================ @tasks.loop(hours=1) async def squadron_stats_tracker_task(): """Track squadron points every hour during SQB timeslot hours.""" now_utc = datetime.now(timezone.utc).time() if any(start <= now_utc <= end for _, start, end in SQB_STATS_TRACKER_WINDOWS): try: await execute_squadron_stats_tracker() await _record("squadron_stats_tracker", True) except Exception as e: await _record("squadron_stats_tracker", False, str(e)) raise @tasks.loop(seconds=60) async def squadron_stats_boundary_task(): """Track squadron points at timeslot boundaries (see SQB_BOUNDARY_TIMES in utils).""" now_utc = datetime.now(timezone.utc) if now_utc.time().replace(second=0, microsecond=0) in SQB_BOUNDARY_TIMES: try: await execute_squadron_stats_tracker() await _record("squadron_stats_boundary", True) except Exception as e: await _record("squadron_stats_boundary", False, str(e)) raise @squadron_stats_tracker_task.before_loop async def before_squadron_stats_tracker_task(): await get_bot().wait_until_ready() await init_squadrons_points_table() @squadron_stats_boundary_task.before_loop async def before_squadron_stats_boundary_task(): await get_bot().wait_until_ready() # ============================================================================ # ENTITLEMENT CACHE REFRESH (hourly fallback) # ============================================================================ @tasks.loop(hours=1) async def entitlement_cache_task(): """Refresh the entitlement cache hourly so new subscriptions are picked up even when no autolog batches or points alarms are running.""" try: await refresh_entitled_guilds(force=True) await _record("entitlement_cache", True) except Exception as e: await _record("entitlement_cache", False, str(e)) raise @entitlement_cache_task.before_loop async def before_entitlement_cache(): await get_bot().wait_until_ready() # ============================================================================ # POINTS ALARM TASK # ============================================================================ @tasks.loop(seconds=60) async def points_alarm_task(): """Checks time and triggers points alarm at 22:25 and 7:25 UTC.""" now_utc = datetime.now(timezone.utc).time() region = None if now_utc.hour == 22 and now_utc.minute == 25: region = "EU" elif now_utc.hour == 7 and now_utc.minute == 25: region = "NA" if region: try: await execute_points_alarm_task(region) await _record("points_alarm", True) except Exception as e: await _record("points_alarm", False, str(e)) raise @points_alarm_task.before_loop async def before_points_alarm_task(): await get_bot().wait_until_ready() # ============================================================================ # REPLAY CLEANUP TASK # ============================================================================ @tasks.loop(hours=4) async def replay_cleanup_task(): """Cleans up replay directories every 4 hours.""" try: await cleanup_replays() await _record("replay_cleanup", True) except Exception as e: await _record("replay_cleanup", False, str(e)) raise @replay_cleanup_task.before_loop async def before_replay_cleanup_task(): await get_bot().wait_until_ready() # ============================================================================ # LEAVE ALARM TASK # ============================================================================ @tasks.loop(minutes=30) async def leave_alarm_task(): """Runs leave alarm every 30 minutes.""" try: await execute_leave_alarm_task() await _record("leave_alarm", True) except Exception as e: await _record("leave_alarm", False, str(e)) raise @leave_alarm_task.before_loop async def before_leave_alarm_task(): await get_bot().wait_until_ready() # ============================================================================ # UPDATE SQUADRONS DB TASK # ============================================================================ _startup_db_done = False @tasks.loop(hours=1) async def update_squadrons_db_task(): """ Every 1 hour: 1. Refresh the main squadrons.db via Game API. 2. Sync squadron member UIDs for top 1000 squadrons (bulk API). 3. Update the Bot's status """ global _startup_db_done if not _startup_db_done: _startup_db_done = True return # Skip — already ran in _startup_heavy_init try: await execute_update_squadrons_db_task(count=1000) await execute_sync_squadron_members_bulk(count=1000) await execute_cleanup_stale_squadrons() await _record("update_squadrons_db", True) except Exception as e: await _record("update_squadrons_db", False, str(e)) raise @update_squadrons_db_task.before_loop async def before_update_squadrons_db_task(): await get_bot().wait_until_ready() # ============================================================================ # UPDATE META DATA TASK # ============================================================================ @tasks.loop(hours=12) async def update_meta_data_task(): """ Every 12 hours: Update all player vehicle data in Meta.db from the game API. Skips players that were updated within 2 days. """ try: await process_all_players( limit=None, skip_existing=True, batch_size=50, max_concurrent=5 ) await _record("update_meta_data", True) except Exception as e: await _record("update_meta_data", False, str(e)) logging.error(f"[META-DB] Error during meta data update: {e}") @update_meta_data_task.before_loop async def before_update_meta_data_task(): await get_bot().wait_until_ready() # ============================================================================ # WEBSOCKET AUTOLOG TASK # ============================================================================ @tasks.loop(count=1) async def ws_autolog_task(): """ Single-run task that maintains persistent WebSocket connection. Replaces the polling-based auto_logging_task. """ await lux_apis.ws_replay_listener(handle_ws_replays) @ws_autolog_task.before_loop async def before_ws_autolog(): await get_bot().wait_until_ready() @ws_autolog_task.after_loop async def after_ws_autolog(): if ws_autolog_task.failed(): logging.error("[WS] ws_autolog_task died, restarting in 10s...") await asyncio.sleep(10) ws_autolog_task.start() # ============================================================================ # WEBSOCKET GOB LISTENER TASK # ============================================================================ @tasks.loop(count=1) async def ws_gob_task(): """ Single-run task that maintains persistent WebSocket connection to the GOB endpoint. Saves incoming compressed GOB replays to disk for on-demand video generation. """ await lux_apis.ws_gob_listener(handle_gob_message) @ws_gob_task.before_loop async def before_ws_gob(): await get_bot().wait_until_ready() @ws_gob_task.after_loop async def after_ws_gob(): if ws_gob_task.failed(): logging.error("[GOB] ws_gob_task died, restarting in 10s...") await asyncio.sleep(10) ws_gob_task.start() # ============================================================================ # SQUADRON POINTS CONTINUOUS UPDATER # ============================================================================ @tasks.loop(count=1) async def squadron_points_loop_task(): """ Continuously update squadron member points forever. Very slow (3s per clan), runs indefinitely in the background. """ await execute_sync_squadron_members_points_loop(count=1000, delay=3.0) @squadron_points_loop_task.before_loop async def before_squadron_points_loop(): await get_bot().wait_until_ready() @squadron_points_loop_task.after_loop async def after_squadron_points_loop(): if squadron_points_loop_task.failed(): logging.error("[SQ-POINTS] squadron_points_loop_task died, restarting in 10s...") await asyncio.sleep(10) squadron_points_loop_task.start() # ============================================================================ # STACKS CLEANUP TASK # ============================================================================ # Fires at 07:30 and 22:30 UTC — 20 minutes after each SQB timeslot ends. _STACKS_CLEANUP_TIMES = [(7, 30), (22, 30)] @tasks.loop(seconds=60) async def stacks_cleanup_task(): """Wipe all active stacks at the end of each SQB timeslot.""" now_utc = datetime.now(timezone.utc) if (now_utc.hour, now_utc.minute) in _STACKS_CLEANUP_TIMES: try: if STACKS_DIR.exists(): shutil.rmtree(STACKS_DIR) STACKS_DIR.mkdir(parents=True, exist_ok=True) logging.info("[STACKS] Wiped STACKS_DIR at timeslot end.") await _record("stacks_cleanup", True) except Exception as e: await _record("stacks_cleanup", False, str(e)) logging.error(f"[STACKS] Failed to wipe STACKS_DIR: {e}") @stacks_cleanup_task.before_loop async def before_stacks_cleanup_task(): await get_bot().wait_until_ready() # ============================================================================ # HEALTH HEARTBEAT TASK # ============================================================================ @tasks.loop(seconds=30) async def health_heartbeat_task(): """Write bot health snapshot every 30 seconds.""" await write_heartbeat() @health_heartbeat_task.before_loop async def before_health_heartbeat_task(): await get_bot().wait_until_ready() # ============================================================================ # GUILD META MEMBER SYNC TASK # ============================================================================ @tasks.loop(hours=24) async def sync_guild_metas_task(): """ Every 24 hours: for each configured guild, fetch the current squadron roster from the Game API and reconcile Guild_Metas — adding new members and removing players who have left the squadron. """ try: await sync_all_guild_metas() await _record("sync_guild_metas", True) except Exception as e: await _record("sync_guild_metas", False, str(e)) logging.error(f"[META-SYNC] Error during daily guild meta sync: {e}") @sync_guild_metas_task.before_loop async def before_sync_guild_metas_task(): await get_bot().wait_until_ready() # ============================================================================ # WEEKLY BR REPORT TASK # ============================================================================ # Fires ~10 min after each BR window ends (10 * 60 = 600s). _WEEKLY_BR_FIRE_OFFSET_S = 600 _WEEKLY_BR_FIRE_TOLERANCE_S = 60 # tolerance window for the 60s loop tick _SCHEDULE_PATH = Path(__file__).parent / "SCHEDULE.json" def _just_ended_br_window(now_ts: int) -> Optional[Dict[str, Any]]: """Return the SCHEDULE.json entry whose end is `_WEEKLY_BR_FIRE_OFFSET_S` seconds in the past (within `_WEEKLY_BR_FIRE_TOLERANCE_S`), or None. """ try: with open(_SCHEDULE_PATH, "r", encoding="utf-8") as fp: schedule = json.load(fp) except Exception as e: logging.error("[WBR] Failed to read SCHEDULE.json: %s", e) return None target = now_ts - _WEEKLY_BR_FIRE_OFFSET_S for entry in schedule: try: end_ts = int(entry["end"]) except (KeyError, TypeError, ValueError): continue if target - _WEEKLY_BR_FIRE_TOLERANCE_S <= end_ts <= target: return entry return None @tasks.loop(seconds=60) async def weekly_br_report_task(): """Fire the Weekly BR Report ~10 min after each BR window ends.""" now_ts = int(datetime.now(timezone.utc).timestamp()) window = _just_ended_br_window(now_ts) if window is None: return try: await execute_weekly_br_report_task(window) await _record("weekly_br_report", True) except Exception as e: await _record("weekly_br_report", False, str(e)) raise @weekly_br_report_task.before_loop async def before_weekly_br_report_task(): await get_bot().wait_until_ready() # ============================================================================ # TASK STARTER FUNCTION # ============================================================================ async def _startup_heavy_init(): """Run heavy DB/API tasks at startup, then start recurring tasks.""" try: await execute_update_squadrons_db_task(count=1000) except Exception as e: logging.error(f"[STARTUP] squadrons.db update failed: {e}") try: await execute_sync_squadron_members_bulk(count=1000) except Exception as e: logging.error(f"[STARTUP] squadron members sync failed: {e}") try: await execute_cleanup_stale_squadrons() except Exception as e: logging.error(f"[STARTUP] stale squadron cleanup failed: {e}") await asyncio.sleep(5) # Recurring tasks (started after heavy init completes) update_squadrons_db_task.start() squadron_stats_tracker_task.start() leave_alarm_task.start() # Continuous background tasks squadron_points_loop_task.start() update_meta_data_task.start() sync_guild_metas_task.start() async def start_all_tasks(): """ Start all background tasks. Heavy DB init runs in a background task so on_ready returns quickly. Order: 1. Lightweight time-check tasks (just check clock, no heavy work) 2. WebSocket listeners (persistent connections) 3. Heavy DB/API tasks (background — does not block on_ready) """ # Phase 1: Lightweight time-check tasks entitlement_cache_task.start() ldb_alarm_task.start() squadron_stats_boundary_task.start() points_alarm_task.start() stacks_cleanup_task.start() replay_cleanup_task.start() health_heartbeat_task.start() weekly_br_report_task.start() # Phase 2: WebSocket listeners ws_autolog_task.start() ws_gob_task.start() # Phase 3: Heavy DB tasks (background — doesn't block on_ready) asyncio.create_task(_startup_heavy_init()) def stop_all_tasks(): """Stop all background tasks. Call this on shutdown.""" entitlement_cache_task.cancel() ldb_alarm_task.cancel() squadron_stats_tracker_task.cancel() squadron_stats_boundary_task.cancel() points_alarm_task.cancel() stacks_cleanup_task.cancel() replay_cleanup_task.cancel() leave_alarm_task.cancel() update_squadrons_db_task.cancel() update_meta_data_task.cancel() ws_autolog_task.cancel() ws_gob_task.cancel() squadron_points_loop_task.cancel() sync_guild_metas_task.cancel() health_heartbeat_task.cancel() weekly_br_report_task.cancel()