Files
SREBOT/BOT/tasks.py
T
FURRO404 2b399fdb81 add SREBOT, SHARED, TSSBOT contents (fixup for #1223)
PR #1223 only staged the deletions of the old paths because the new
top-level directories were still untracked when the commit was authored.
This commit adds the actual restructured tree: SREBOT/ (existing bot),
SHARED/ (vromfs, data_parser, ICONS/MAPS/FONTS, DAGOR_FILES,
update_game_files), and TSSBOT/ (skeleton).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:17:02 -07:00

544 lines
17 KiB
Python

"""
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()