wuhp commited on
Commit
dfd46e0
·
verified ·
1 Parent(s): 565bba4

Upload 6 files

Browse files
Files changed (6) hide show
  1. .dockerignore +7 -0
  2. .gitignore +5 -0
  3. Dockerfile +19 -0
  4. main.py +1490 -0
  5. requirements.txt +5 -0
  6. tracker_client.py +238 -0
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.db
5
+ .venv/
6
+ .git/
7
+ .env
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .env
5
+ rocket_watch.db
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PORT=7860 \
6
+ DB_PATH=/data/rocket_watch.db
7
+
8
+ WORKDIR /app
9
+
10
+ COPY requirements.txt ./
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ COPY main.py tracker_client.py ./
14
+
15
+ RUN mkdir -p /data
16
+
17
+ EXPOSE 7860
18
+
19
+ CMD ["python", "main.py"]
main.py ADDED
@@ -0,0 +1,1490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ import sqlite3
8
+ import socket
9
+ import time
10
+ import base64
11
+ from collections import Counter
12
+ from io import BytesIO
13
+ from datetime import datetime, timezone, timedelta
14
+ from pathlib import Path
15
+ from typing import Any, Optional
16
+
17
+ import discord
18
+ from discord import app_commands
19
+ from discord.ext import tasks
20
+ from fastapi import FastAPI
21
+ import aiohttp
22
+ import uvicorn
23
+ import requests
24
+ import yarl
25
+ import matplotlib
26
+ matplotlib.use("Agg")
27
+ import matplotlib.pyplot as plt
28
+
29
+ from tracker_client import TrackerClient, TrackerError
30
+
31
+ APP_DIR = Path(__file__).resolve().parent
32
+ DB_PATH = Path(os.getenv("DB_PATH", str(APP_DIR / "rocket_watch.db")))
33
+ POLL_SECONDS = max(60, int(os.getenv("POLL_SECONDS", "120")))
34
+ ALERT_MAX_AGE_MINUTES = max(5, int(os.getenv("ALERT_MAX_AGE_MINUTES", "30")))
35
+ DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN", "").strip()
36
+ GUILD_ID = os.getenv("DISCORD_GUILD_ID", "").strip()
37
+ DISCORD_LOGIN_TIMEOUT = max(5, int(os.getenv("DISCORD_LOGIN_TIMEOUT", "20")))
38
+ DISCORD_READY_TIMEOUT = max(10, int(os.getenv("DISCORD_READY_TIMEOUT", "30")))
39
+ DISCORD_RELAY_ORIGIN = (os.getenv("DISCORD_RELAY_ORIGIN") or os.getenv("DISCORD_PROXY_ORIGIN", "")).strip().rstrip("/")
40
+ DISCORD_RELAY_SECRET = (os.getenv("DISCORD_RELAY_SECRET") or os.getenv("DISCORD_PROXY_SECRET", "")).strip().strip("/")
41
+ GHT = os.getenv("GHT", "").strip()
42
+ GITHUB_DATA_REPO = os.getenv("GITHUB_DATA_REPO", "Wuhpondiscord/rldevs").strip()
43
+ GITHUB_DATA_BRANCH = os.getenv("GITHUB_DATA_BRANCH", "main").strip() or "main"
44
+ GITHUB_SYNC_ACTIVITY_LIMIT = max(100, int(os.getenv("GITHUB_SYNC_ACTIVITY_LIMIT", "5000")))
45
+ GITHUB_AUTO_SYNC_SECONDS = max(60, int(os.getenv("GITHUB_AUTO_SYNC_SECONDS", "300")))
46
+
47
+ logging.basicConfig(
48
+ level=os.getenv("LOG_LEVEL", "INFO").upper(),
49
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
50
+ )
51
+ log = logging.getLogger("rocket-watch")
52
+
53
+
54
+ def configure_discord_relay() -> Optional[str]:
55
+ """Route discord.py REST + Gateway traffic through the external Deno relay.
56
+
57
+ The relay uses a secret path segment so it cannot be used as an open Discord
58
+ proxy by anyone who does not know the shared secret. The bot token is not
59
+ stored in the relay; discord.py sends it as the normal Authorization header.
60
+ """
61
+ if not DISCORD_RELAY_ORIGIN and not DISCORD_RELAY_SECRET:
62
+ return None
63
+ if not DISCORD_RELAY_ORIGIN or not DISCORD_RELAY_SECRET:
64
+ raise RuntimeError(
65
+ "Set both DISCORD_RELAY_ORIGIN and DISCORD_RELAY_SECRET, or neither."
66
+ )
67
+ if not DISCORD_RELAY_ORIGIN.startswith("https://"):
68
+ raise RuntimeError("DISCORD_RELAY_ORIGIN must start with https://")
69
+
70
+ from discord.http import Route
71
+ from discord.gateway import DiscordWebSocket
72
+
73
+ rest_base = f"{DISCORD_RELAY_ORIGIN}/p/{DISCORD_RELAY_SECRET}/api/v10"
74
+ gateway = DISCORD_RELAY_ORIGIN.replace("https://", "wss://", 1)
75
+ gateway = f"{gateway}/p/{DISCORD_RELAY_SECRET}/gateway"
76
+
77
+ Route.BASE = rest_base
78
+ DiscordWebSocket.DEFAULT_GATEWAY = yarl.URL(gateway)
79
+
80
+ # Discord's READY event contains a direct resume_gateway_url. discord.py stores
81
+ # that URL for later RESUME attempts. Restore our proxy URL immediately after
82
+ # READY/RESUMED processing so reconnects never escape the relay.
83
+ original_received_message = DiscordWebSocket.received_message
84
+
85
+ async def proxied_received_message(self, msg):
86
+ result = await original_received_message(self, msg)
87
+ if getattr(self, "session_id", None) and str(getattr(self, "gateway", "")) != gateway:
88
+ self.gateway = yarl.URL(gateway)
89
+ return result
90
+
91
+ if not getattr(DiscordWebSocket.received_message, "_rocket_watch_proxy_patch", False):
92
+ proxied_received_message._rocket_watch_proxy_patch = True
93
+ DiscordWebSocket.received_message = proxied_received_message
94
+
95
+ log.info("Discord relay enabled through %s", DISCORD_RELAY_ORIGIN)
96
+ return gateway
97
+
98
+
99
+ DISCORD_RELAY_GATEWAY = configure_discord_relay()
100
+
101
+
102
+ def utc_now_iso() -> str:
103
+ return datetime.now(timezone.utc).isoformat()
104
+
105
+
106
+ def parse_dt(value: Optional[str]) -> Optional[datetime]:
107
+ if not value:
108
+ return None
109
+ try:
110
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
111
+ if dt.tzinfo is None:
112
+ dt = dt.replace(tzinfo=timezone.utc)
113
+ return dt.astimezone(timezone.utc)
114
+ except ValueError:
115
+ return None
116
+
117
+
118
+ def age_text(value: Optional[str]) -> str:
119
+ dt = parse_dt(value)
120
+ if not dt:
121
+ return "unknown"
122
+ sec = max(0, int((datetime.now(timezone.utc) - dt).total_seconds()))
123
+ if sec < 60:
124
+ return f"{sec}s ago"
125
+ if sec < 3600:
126
+ return f"{sec // 60}m ago"
127
+ if sec < 86400:
128
+ return f"{sec // 3600}h {(sec % 3600) // 60}m ago"
129
+ return f"{sec // 86400}d ago"
130
+
131
+
132
+ def discord_time(value: Optional[str], style: str = "F") -> str:
133
+ """Render a timestamp in each Discord viewer's own locale/timezone."""
134
+ dt = parse_dt(value)
135
+ if not dt:
136
+ return "unknown"
137
+ return f"<t:{int(dt.timestamp())}:{style}>"
138
+
139
+
140
+ def queue_window_text(value: Optional[str]) -> str:
141
+ """Conservative inference from Tracker collection time; never claims live presence."""
142
+ dt = parse_dt(value)
143
+ if not dt:
144
+ return "Unknown"
145
+ age_min = max(0, (datetime.now(timezone.utc) - dt).total_seconds() / 60)
146
+ if age_min <= 3:
147
+ return "**Very fresh result** — strongest window to try the same playlist/MMR"
148
+ if age_min <= 8:
149
+ return "**Fresh** — they may still be playing or re-queueing"
150
+ if age_min <= 20:
151
+ return "**Recent** — useful signal, but queue status is uncertain"
152
+ return "**Stale** — weak signal for current matchmaking"
153
+
154
+
155
+ def activity_label(value: Optional[str]) -> str:
156
+ dt = parse_dt(value)
157
+ if not dt:
158
+ return "UNKNOWN"
159
+ age = (datetime.now(timezone.utc) - dt).total_seconds()
160
+ if age <= 8 * 60:
161
+ return "HOT"
162
+ if age <= 20 * 60:
163
+ return "RECENT"
164
+ if age <= 60 * 60:
165
+ return "COOLING"
166
+ return "INACTIVE"
167
+
168
+
169
+ def trunc(s: str, n: int) -> str:
170
+ return s if len(s) <= n else s[: n - 1] + "…"
171
+
172
+
173
+ class Store:
174
+ def __init__(self, path: Path):
175
+ self.path = path
176
+ self.path.parent.mkdir(parents=True, exist_ok=True)
177
+ self._init()
178
+
179
+ def conn(self):
180
+ conn = sqlite3.connect(self.path, timeout=30)
181
+ conn.row_factory = sqlite3.Row
182
+ return conn
183
+
184
+ def _init(self):
185
+ with self.conn() as c:
186
+ c.executescript(
187
+ """
188
+ PRAGMA journal_mode=WAL;
189
+ CREATE TABLE IF NOT EXISTS players (
190
+ id INTEGER PRIMARY KEY,
191
+ guild_id INTEGER NOT NULL,
192
+ label TEXT NOT NULL,
193
+ platform TEXT NOT NULL,
194
+ identifier TEXT NOT NULL,
195
+ handle TEXT,
196
+ tracker_player_id INTEGER,
197
+ alert_channel_id INTEGER,
198
+ enabled INTEGER NOT NULL DEFAULT 1,
199
+ created_at TEXT NOT NULL,
200
+ alert_watermark_at TEXT,
201
+ UNIQUE(guild_id, platform, identifier)
202
+ );
203
+
204
+ CREATE TABLE IF NOT EXISTS snapshots (
205
+ id INTEGER PRIMARY KEY,
206
+ player_id INTEGER NOT NULL,
207
+ observed_at TEXT NOT NULL,
208
+ tracker_updated_at TEXT,
209
+ playlist_json TEXT NOT NULL,
210
+ FOREIGN KEY(player_id) REFERENCES players(id)
211
+ );
212
+ CREATE INDEX IF NOT EXISTS idx_snapshots_player_time
213
+ ON snapshots(player_id, observed_at DESC);
214
+
215
+ CREATE TABLE IF NOT EXISTS matches (
216
+ id INTEGER PRIMARY KEY,
217
+ player_id INTEGER NOT NULL,
218
+ external_id TEXT NOT NULL,
219
+ collected_at TEXT,
220
+ playlist TEXT,
221
+ result TEXT,
222
+ rating INTEGER,
223
+ rating_delta INTEGER,
224
+ tier TEXT,
225
+ division TEXT,
226
+ is_grouped INTEGER,
227
+ matches_played INTEGER,
228
+ wins INTEGER,
229
+ goals INTEGER,
230
+ assists INTEGER,
231
+ saves INTEGER,
232
+ shots INTEGER,
233
+ mvps INTEGER,
234
+ first_seen_at TEXT NOT NULL,
235
+ raw_json TEXT,
236
+ FOREIGN KEY(player_id) REFERENCES players(id),
237
+ UNIQUE(player_id, external_id)
238
+ );
239
+ CREATE INDEX IF NOT EXISTS idx_matches_player_collected
240
+ ON matches(player_id, collected_at DESC);
241
+ """
242
+ )
243
+ cols = {row["name"] for row in c.execute("PRAGMA table_info(players)")}
244
+ if "alert_watermark_at" not in cols:
245
+ c.execute("ALTER TABLE players ADD COLUMN alert_watermark_at TEXT")
246
+
247
+ def add_player(self, guild_id: int, label: str, platform: str, identifier: str, channel_id: int) -> int:
248
+ with self.conn() as c:
249
+ c.execute(
250
+ """
251
+ INSERT INTO players(guild_id,label,platform,identifier,alert_channel_id,created_at)
252
+ VALUES(?,?,?,?,?,?)
253
+ ON CONFLICT(guild_id, platform, identifier) DO UPDATE SET
254
+ label=excluded.label,
255
+ alert_channel_id=excluded.alert_channel_id,
256
+ enabled=1
257
+ """,
258
+ (guild_id, label, platform, identifier, channel_id, utc_now_iso()),
259
+ )
260
+ row = c.execute(
261
+ "SELECT id FROM players WHERE guild_id=? AND platform=? AND identifier=?",
262
+ (guild_id, platform, identifier),
263
+ ).fetchone()
264
+ return int(row["id"])
265
+
266
+ def remove_player(self, guild_id: int, query: str) -> bool:
267
+ with self.conn() as c:
268
+ cur = c.execute(
269
+ "DELETE FROM players WHERE guild_id=? AND (label=? COLLATE NOCASE OR identifier=? COLLATE NOCASE)",
270
+ (guild_id, query, query),
271
+ )
272
+ return cur.rowcount > 0
273
+
274
+ def players(self, guild_id: Optional[int] = None):
275
+ with self.conn() as c:
276
+ if guild_id is None:
277
+ return c.execute("SELECT * FROM players WHERE enabled=1 ORDER BY guild_id,label").fetchall()
278
+ return c.execute(
279
+ "SELECT * FROM players WHERE enabled=1 AND guild_id=? ORDER BY label COLLATE NOCASE",
280
+ (guild_id,),
281
+ ).fetchall()
282
+
283
+ def get_player(self, guild_id: int, query: str):
284
+ with self.conn() as c:
285
+ return c.execute(
286
+ """
287
+ SELECT * FROM players
288
+ WHERE guild_id=? AND enabled=1
289
+ AND (label=? COLLATE NOCASE OR handle=? COLLATE NOCASE OR identifier=? COLLATE NOCASE)
290
+ LIMIT 1
291
+ """,
292
+ (guild_id, query, query, query),
293
+ ).fetchone()
294
+
295
+ def update_identity(self, player_id: int, handle: str, tracker_player_id: Optional[int]):
296
+ with self.conn() as c:
297
+ c.execute(
298
+ "UPDATE players SET handle=?, tracker_player_id=? WHERE id=?",
299
+ (handle, tracker_player_id, player_id),
300
+ )
301
+
302
+ def set_alert_watermark(self, player_id: int, value: Optional[str]) -> None:
303
+ if not value:
304
+ return
305
+ with self.conn() as c:
306
+ c.execute("UPDATE players SET alert_watermark_at=? WHERE id=?", (value, player_id))
307
+
308
+ def ensure_alert_watermark(self, player_id: int) -> Optional[str]:
309
+ with self.conn() as c:
310
+ row = c.execute("SELECT alert_watermark_at, created_at FROM players WHERE id=?", (player_id,)).fetchone()
311
+ if not row:
312
+ return None
313
+ if row["alert_watermark_at"]:
314
+ return row["alert_watermark_at"]
315
+ latest = c.execute(
316
+ "SELECT collected_at FROM matches WHERE player_id=? AND collected_at IS NOT NULL ORDER BY collected_at DESC LIMIT 1",
317
+ (player_id,),
318
+ ).fetchone()
319
+ value = latest["collected_at"] if latest else row["created_at"]
320
+ c.execute("UPDATE players SET alert_watermark_at=? WHERE id=?", (value, player_id))
321
+ return value
322
+
323
+ def add_snapshot(self, player_id: int, profile) -> None:
324
+ with self.conn() as c:
325
+ c.execute(
326
+ "INSERT INTO snapshots(player_id,observed_at,tracker_updated_at,playlist_json) VALUES(?,?,?,?)",
327
+ (
328
+ player_id,
329
+ utc_now_iso(),
330
+ profile.last_updated.isoformat() if profile.last_updated else None,
331
+ json.dumps(profile.playlists, separators=(",", ":")),
332
+ ),
333
+ )
334
+
335
+ def latest_snapshot(self, player_id: int):
336
+ with self.conn() as c:
337
+ return c.execute(
338
+ "SELECT * FROM snapshots WHERE player_id=? ORDER BY observed_at DESC LIMIT 1",
339
+ (player_id,),
340
+ ).fetchone()
341
+
342
+ def insert_matches(self, player_id: int, matches: list[dict[str, Any]]) -> list[dict[str, Any]]:
343
+ newly_added: list[dict[str, Any]] = []
344
+ with self.conn() as c:
345
+ for m in matches:
346
+ ext = m.get("match_id")
347
+ if not ext:
348
+ continue
349
+ cur = c.execute(
350
+ """
351
+ INSERT OR IGNORE INTO matches(
352
+ player_id,external_id,collected_at,playlist,result,rating,rating_delta,tier,division,
353
+ is_grouped,matches_played,wins,goals,assists,saves,shots,mvps,first_seen_at,raw_json
354
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
355
+ """,
356
+ (
357
+ player_id, ext, m.get("collected_at"), m.get("playlist"), m.get("result"),
358
+ m.get("rating"), m.get("rating_delta"), m.get("tier"), m.get("division"),
359
+ int(bool(m.get("is_grouped"))), m.get("matches_played"), m.get("wins"),
360
+ m.get("goals"), m.get("assists"), m.get("saves"), m.get("shots"), m.get("mvps"),
361
+ utc_now_iso(), json.dumps(m, separators=(",", ":")),
362
+ ),
363
+ )
364
+ if cur.rowcount:
365
+ newly_added.append(m)
366
+ return newly_added
367
+
368
+ def recent_matches(self, player_id: int, limit: int = 10):
369
+ with self.conn() as c:
370
+ return c.execute(
371
+ "SELECT * FROM matches WHERE player_id=? ORDER BY collected_at DESC LIMIT ?",
372
+ (player_id, limit),
373
+ ).fetchall()
374
+
375
+ def latest_match(self, player_id: int):
376
+ rows = self.recent_matches(player_id, 1)
377
+ return rows[0] if rows else None
378
+
379
+ def matches_since(self, player_id: int, since_iso: str):
380
+ with self.conn() as c:
381
+ return c.execute(
382
+ """
383
+ SELECT * FROM matches
384
+ WHERE player_id=? AND collected_at IS NOT NULL AND collected_at>=?
385
+ ORDER BY collected_at ASC, id ASC
386
+ """,
387
+ (player_id, since_iso),
388
+ ).fetchall()
389
+
390
+
391
+ def export_players(self) -> list[dict[str, Any]]:
392
+ with self.conn() as c:
393
+ rows = c.execute("SELECT * FROM players ORDER BY guild_id,label COLLATE NOCASE").fetchall()
394
+ return [dict(r) for r in rows]
395
+
396
+ def export_matches(self, limit: int = 5000) -> list[dict[str, Any]]:
397
+ with self.conn() as c:
398
+ rows = c.execute(
399
+ """
400
+ SELECT p.guild_id,p.platform,p.identifier,p.label,m.*
401
+ FROM matches m JOIN players p ON p.id=m.player_id
402
+ ORDER BY m.collected_at DESC, m.id DESC LIMIT ?
403
+ """,
404
+ (limit,),
405
+ ).fetchall()
406
+ out = []
407
+ for r in rows:
408
+ d = dict(r)
409
+ d.pop("id", None)
410
+ d.pop("player_id", None)
411
+ out.append(d)
412
+ return out
413
+
414
+ def restore_player(self, item: dict[str, Any]) -> Optional[int]:
415
+ try:
416
+ guild_id = int(item["guild_id"])
417
+ platform = str(item["platform"])
418
+ identifier = str(item["identifier"])
419
+ label = str(item.get("label") or identifier)
420
+ channel_id = int(item.get("alert_channel_id") or 0)
421
+ except (KeyError, TypeError, ValueError):
422
+ return None
423
+ with self.conn() as c:
424
+ c.execute(
425
+ """
426
+ INSERT INTO players(
427
+ guild_id,label,platform,identifier,handle,tracker_player_id,
428
+ alert_channel_id,enabled,created_at,alert_watermark_at
429
+ ) VALUES(?,?,?,?,?,?,?,?,?,?)
430
+ ON CONFLICT(guild_id,platform,identifier) DO UPDATE SET
431
+ label=excluded.label,
432
+ handle=COALESCE(excluded.handle,players.handle),
433
+ tracker_player_id=COALESCE(excluded.tracker_player_id,players.tracker_player_id),
434
+ alert_channel_id=CASE WHEN excluded.alert_channel_id>0 THEN excluded.alert_channel_id ELSE players.alert_channel_id END,
435
+ enabled=excluded.enabled,
436
+ alert_watermark_at=COALESCE(excluded.alert_watermark_at,players.alert_watermark_at)
437
+ """,
438
+ (
439
+ guild_id,label,platform,identifier,item.get("handle"),item.get("tracker_player_id"),
440
+ channel_id,int(item.get("enabled",1)),item.get("created_at") or utc_now_iso(),item.get("alert_watermark_at"),
441
+ ),
442
+ )
443
+ row=c.execute(
444
+ "SELECT id FROM players WHERE guild_id=? AND platform=? AND identifier=?",
445
+ (guild_id,platform,identifier),
446
+ ).fetchone()
447
+ return int(row["id"]) if row else None
448
+
449
+ def restore_match(self, player_id: int, item: dict[str, Any]) -> None:
450
+ ext = item.get("external_id") or item.get("match_id")
451
+ if not ext:
452
+ return
453
+ with self.conn() as c:
454
+ c.execute(
455
+ """
456
+ INSERT OR IGNORE INTO matches(
457
+ player_id,external_id,collected_at,playlist,result,rating,rating_delta,tier,division,
458
+ is_grouped,matches_played,wins,goals,assists,saves,shots,mvps,first_seen_at,raw_json
459
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
460
+ """,
461
+ (
462
+ player_id,ext,item.get("collected_at"),item.get("playlist"),item.get("result"),
463
+ item.get("rating"),item.get("rating_delta"),item.get("tier"),item.get("division"),
464
+ int(bool(item.get("is_grouped"))),item.get("matches_played"),item.get("wins"),
465
+ item.get("goals"),item.get("assists"),item.get("saves"),item.get("shots"),item.get("mvps"),
466
+ item.get("first_seen_at") or utc_now_iso(),item.get("raw_json") or json.dumps(item,separators=(",",":")),
467
+ ),
468
+ )
469
+
470
+
471
+ class GitHubDataStore:
472
+ API = "https://api.github.com"
473
+
474
+ def __init__(self, token: str, repo: str, branch: str = "main"):
475
+ self.token = token
476
+ self.repo = repo
477
+ self.branch = branch
478
+ self.enabled = bool(token and repo)
479
+ self.session = requests.Session()
480
+ if self.enabled:
481
+ self.session.headers.update({
482
+ "Authorization": f"Bearer {token}",
483
+ "Accept": "application/vnd.github+json",
484
+ "X-GitHub-Api-Version": "2022-11-28",
485
+ "User-Agent": "rocket-watch-hf/1.0",
486
+ })
487
+ self.last_sync_at: Optional[str] = None
488
+ self.last_error: Optional[str] = None
489
+
490
+ def _url(self, path: str) -> str:
491
+ return f"{self.API}/repos/{self.repo}/contents/{path}"
492
+
493
+ def read_json(self, path: str, default: dict[str, Any]) -> tuple[dict[str, Any], Optional[str]]:
494
+ if not self.enabled:
495
+ return default, None
496
+ r = self.session.get(self._url(path), params={"ref": self.branch}, timeout=20)
497
+ if r.status_code == 404:
498
+ return default, None
499
+ r.raise_for_status()
500
+ payload = r.json()
501
+ raw = base64.b64decode(payload.get("content", "")).decode("utf-8")
502
+ return json.loads(raw), payload.get("sha")
503
+
504
+ def write_json(self, path: str, data: dict[str, Any], message: str) -> None:
505
+ if not self.enabled:
506
+ return
507
+ _, sha = self.read_json(path, {})
508
+ body = {
509
+ "message": message,
510
+ "content": base64.b64encode((json.dumps(data, indent=2, sort_keys=False) + "\n").encode()).decode(),
511
+ "branch": self.branch,
512
+ }
513
+ if sha:
514
+ body["sha"] = sha
515
+ r = self.session.put(self._url(path), json=body, timeout=30)
516
+ r.raise_for_status()
517
+
518
+ def ensure_files(self) -> None:
519
+ if not self.enabled:
520
+ return
521
+ defaults = {
522
+ "datasets/users.json": {"schema_version": 1, "updated_at": None, "players": []},
523
+ "datasets/activity.json": {"schema_version": 1, "updated_at": None, "matches": []},
524
+ }
525
+ for path, default in defaults.items():
526
+ data, sha = self.read_json(path, default)
527
+ if sha is None:
528
+ self.write_json(path, data, f"Create {path}")
529
+
530
+ def restore_into(self, store: Store) -> dict[str, int]:
531
+ if not self.enabled:
532
+ return {"players": 0, "matches": 0}
533
+ users, _ = self.read_json("datasets/users.json", {"players": []})
534
+ activity, _ = self.read_json("datasets/activity.json", {"matches": []})
535
+ key_to_id: dict[tuple[int,str,str], int] = {}
536
+ restored_players = 0
537
+ for item in users.get("players") or []:
538
+ pid = store.restore_player(item)
539
+ if pid is not None:
540
+ key_to_id[(int(item["guild_id"]), str(item["platform"]), str(item["identifier"]))] = pid
541
+ restored_players += 1
542
+ restored_matches = 0
543
+ for item in activity.get("matches") or []:
544
+ try:
545
+ key = (int(item["guild_id"]), str(item["platform"]), str(item["identifier"]))
546
+ except (KeyError, TypeError, ValueError):
547
+ continue
548
+ pid = key_to_id.get(key)
549
+ if pid is None:
550
+ row = store.get_player(key[0], key[2])
551
+ pid = int(row["id"]) if row else None
552
+ if pid is not None:
553
+ store.restore_match(pid, item)
554
+ restored_matches += 1
555
+ self.last_sync_at = utc_now_iso()
556
+ self.last_error = None
557
+ return {"players": restored_players, "matches": restored_matches}
558
+
559
+ def push_all(self, store: Store, reason: str = "Sync RL dev tracker datasets") -> None:
560
+ if not self.enabled:
561
+ return
562
+ now = utc_now_iso()
563
+ users = {"schema_version": 1, "updated_at": now, "players": store.export_players()}
564
+ activity = {"schema_version": 1, "updated_at": now, "matches": store.export_matches(GITHUB_SYNC_ACTIVITY_LIMIT)}
565
+ self.write_json("datasets/users.json", users, f"{reason}: users")
566
+ self.write_json("datasets/activity.json", activity, f"{reason}: activity")
567
+ self.last_sync_at = now
568
+ self.last_error = None
569
+
570
+
571
+ github_data = GitHubDataStore(GHT, GITHUB_DATA_REPO, GITHUB_DATA_BRANCH)
572
+
573
+
574
+ store = Store(DB_PATH)
575
+ tracker = TrackerClient()
576
+
577
+
578
+ async def github_push(reason: str = "Sync RL dev tracker datasets") -> bool:
579
+ if not github_data.enabled:
580
+ return False
581
+ try:
582
+ await asyncio.to_thread(github_data.push_all, store, reason)
583
+ return True
584
+ except Exception as exc:
585
+ github_data.last_error = f"{type(exc).__name__}: {exc}"
586
+ log.warning("GitHub dataset sync failed: %s", exc)
587
+ return False
588
+
589
+
590
+ intents = discord.Intents.default()
591
+ client = discord.Client(intents=intents)
592
+ tree = app_commands.CommandTree(client)
593
+
594
+ # Runtime state used by the health endpoint.
595
+ runtime = {
596
+ "discord_connected": False,
597
+ "discord_user": None,
598
+ "discord_last_error": None,
599
+ "discord_last_attempt": None,
600
+ "discord_last_connected": None,
601
+ "discord_retry_seconds": None,
602
+ "discord_stage": "idle",
603
+ "discord_stage_since": None,
604
+ }
605
+
606
+
607
+ async def fetch_and_store(player_row, *, seed: bool = False) -> tuple[Any, list[dict[str, Any]]]:
608
+ profile = await asyncio.to_thread(tracker.profile, player_row["platform"], player_row["identifier"])
609
+ sessions_payload = await asyncio.to_thread(tracker.sessions, player_row["platform"], player_row["identifier"])
610
+ matches = tracker.flatten_session_matches(sessions_payload)
611
+
612
+ store.update_identity(player_row["id"], profile.handle, profile.player_id)
613
+ store.add_snapshot(player_row["id"], profile)
614
+ new_matches = store.insert_matches(player_row["id"], matches)
615
+
616
+ # On initial add, establish a watermark at the newest historical match. Tracker can
617
+ # reveal older/grouped rows later, and those must never be announced as live activity.
618
+ if seed:
619
+ dated = [m for m in matches if parse_dt(m.get("collected_at"))]
620
+ if dated:
621
+ newest = max(dated, key=lambda m: parse_dt(m.get("collected_at")))
622
+ store.set_alert_watermark(player_row["id"], newest.get("collected_at"))
623
+ else:
624
+ store.set_alert_watermark(player_row["id"], utc_now_iso())
625
+ return profile, []
626
+
627
+ return profile, new_matches
628
+
629
+
630
+ def profile_embed(player_row, profile, latest_match=None) -> discord.Embed:
631
+ e = discord.Embed(
632
+ title=f"{profile.handle} — Rocket League",
633
+ description=f"{profile.platform.upper()} • Season {profile.season or '?'}",
634
+ color=discord.Color.blurple(),
635
+ timestamp=datetime.now(timezone.utc),
636
+ )
637
+ if profile.avatar_url:
638
+ e.set_thumbnail(url=profile.avatar_url)
639
+
640
+ if latest_match:
641
+ e.add_field(
642
+ name="Activity",
643
+ value=f"**{activity_label(latest_match['collected_at'])}** • {discord_time(latest_match['collected_at'], 'R')}\n{discord_time(latest_match['collected_at'], 'f')}",
644
+ inline=True,
645
+ )
646
+ e.add_field(name="Last mode", value=latest_match["playlist"] or "Unknown", inline=True)
647
+ if latest_match["rating"] is not None:
648
+ delta = latest_match["rating_delta"]
649
+ ds = f" ({delta:+d})" if delta is not None else ""
650
+ e.add_field(name="Last MMR", value=f"{latest_match['rating']}{ds}", inline=True)
651
+
652
+ ranked = [p for p in profile.playlists if p.get("rating") is not None]
653
+ ranked.sort(key=lambda p: ("Ranked" not in (p.get("name") or ""), p.get("name") or ""))
654
+ for p in ranked[:10]:
655
+ rank = " ".join(x for x in [p.get("tier"), p.get("division")] if x) or "—"
656
+ peak = f" • peak {p['peak_rating']}" if p.get("peak_rating") is not None else ""
657
+ e.add_field(
658
+ name=trunc(p.get("name") or "Playlist", 256),
659
+ value=f"**{p['rating']} MMR** • {rank}{peak}",
660
+ inline=False,
661
+ )
662
+ e.set_footer(text="Tracker collection times can lag actual in-game activity.")
663
+ return e
664
+
665
+
666
+ def match_embed(player_row, match: dict[str, Any]) -> discord.Embed:
667
+ rating = match.get("rating")
668
+ delta = match.get("rating_delta")
669
+ mmr = "Unknown"
670
+ if rating is not None:
671
+ mmr = str(rating)
672
+ if delta is not None:
673
+ mmr += f" ({delta:+d})"
674
+ result = match.get("result") or "activity"
675
+ e = discord.Embed(
676
+ title=f"New RL activity — {player_row['label']}",
677
+ description=f"**{match.get('playlist') or 'Unknown playlist'}** • {result}",
678
+ color=discord.Color.green() if str(result).lower() == "victory" else discord.Color.orange(),
679
+ timestamp=parse_dt(match.get("collected_at")) or datetime.now(timezone.utc),
680
+ )
681
+ e.add_field(name="MMR", value=mmr, inline=True)
682
+ rank = " ".join(x for x in [match.get("tier"), match.get("division")] if x)
683
+ if rank:
684
+ e.add_field(name="Rank", value=rank, inline=True)
685
+ e.add_field(
686
+ name="Observed",
687
+ value=f"{discord_time(match.get('collected_at'), 'R')}\n{discord_time(match.get('collected_at'), 'f')}",
688
+ inline=True,
689
+ )
690
+ e.add_field(name="Matchmaking signal", value=queue_window_text(match.get("collected_at")), inline=False)
691
+ stats = []
692
+ for k, label in [("goals", "G"), ("assists", "A"), ("saves", "S"), ("shots", "Shots")]:
693
+ if match.get(k) is not None:
694
+ stats.append(f"{label}: {match[k]}")
695
+ if stats:
696
+ e.add_field(name="Stats", value=" • ".join(stats), inline=False)
697
+ e.set_footer(text="Activity is inferred from Tracker updates; it is not a direct online-status signal.")
698
+ return e
699
+
700
+
701
+
702
+
703
+ def build_activity_graph(matches, label: str, range_days: int, style: str = "bar") -> tuple[BytesIO, dict[str, Any], str]:
704
+ valid = []
705
+ for m in matches:
706
+ if m["is_grouped"]:
707
+ continue
708
+ dt = parse_dt(m["collected_at"])
709
+ if not dt:
710
+ continue
711
+ valid.append((m, dt))
712
+ if not valid:
713
+ raise ValueError("No non-grouped matches in the selected time range.")
714
+
715
+ hourly = [0] * 24
716
+ dow_hour = [[0 for _ in range(24)] for _ in range(7)]
717
+ playlist_counter: Counter[str] = Counter()
718
+ active_days = set()
719
+ for m, dt in valid:
720
+ hourly[dt.hour] += 1
721
+ dow_hour[dt.weekday()][dt.hour] += 1
722
+ playlist_counter[m["playlist"] or "Unknown"] += 1
723
+ active_days.add(dt.date().isoformat())
724
+
725
+ top_hour = max(range(24), key=lambda h: hourly[h])
726
+ top_hour_count = hourly[top_hour]
727
+ top_day_index = max(range(7), key=lambda d: sum(dow_hour[d]))
728
+ day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
729
+ top_day_count = sum(dow_hour[top_day_index])
730
+ avg_per_active_day = len(valid) / max(1, len(active_days))
731
+ top_playlist, top_playlist_count = playlist_counter.most_common(1)[0]
732
+
733
+ fig = plt.figure(figsize=(10.5, 4.8 if style == "bar" else 5.8))
734
+ ax = fig.add_subplot(111)
735
+ title = f"{label} activity by time of day — last {range_days} day(s)"
736
+
737
+ if style == "heatmap":
738
+ im = ax.imshow(dow_hour, aspect="auto", interpolation="nearest")
739
+ ax.set_title(title + " (UTC)")
740
+ ax.set_xlabel("Hour of day (UTC)")
741
+ ax.set_ylabel("Day of week")
742
+ ax.set_xticks(range(0, 24, 1))
743
+ ax.set_xticklabels([str(h) for h in range(24)], fontsize=8)
744
+ ax.set_yticks(range(7))
745
+ ax.set_yticklabels(day_names)
746
+ fig.colorbar(im, ax=ax, label="Observed matches")
747
+ caption = "Heatmap uses UTC. Static images cannot auto-convert to each viewer's local timezone."
748
+ else:
749
+ ax.bar(list(range(24)), hourly)
750
+ ax.set_title(title + " (UTC)")
751
+ ax.set_xlabel("Hour of day (UTC)")
752
+ ax.set_ylabel("Observed matches")
753
+ ax.set_xticks(range(24))
754
+ ax.set_xlim(-0.6, 23.6)
755
+ ax.grid(True, axis="y", alpha=0.3)
756
+ caption = "Bar chart uses UTC. Static images cannot auto-convert to each viewer's local timezone."
757
+
758
+ fig.tight_layout()
759
+ buf = BytesIO()
760
+ fig.savefig(buf, format="png", dpi=170, bbox_inches="tight")
761
+ plt.close(fig)
762
+ buf.seek(0)
763
+
764
+ stats = {
765
+ "match_count": len(valid),
766
+ "active_days": len(active_days),
767
+ "avg_per_active_day": avg_per_active_day,
768
+ "top_hour": top_hour,
769
+ "top_hour_count": top_hour_count,
770
+ "top_day": day_names[top_day_index],
771
+ "top_day_count": top_day_count,
772
+ "top_playlist": top_playlist,
773
+ "top_playlist_count": top_playlist_count,
774
+ }
775
+ return buf, stats, caption
776
+
777
+ @client.event
778
+ async def on_ready():
779
+ runtime["discord_connected"] = True
780
+ runtime["discord_user"] = str(client.user) if client.user else None
781
+ runtime["discord_last_connected"] = utc_now_iso()
782
+ runtime["discord_last_error"] = None
783
+ runtime["discord_retry_seconds"] = None
784
+ log.info("Logged in as %s (%s)", client.user, client.user.id if client.user else "?")
785
+ if not watcher.is_running():
786
+ watcher.change_interval(seconds=POLL_SECONDS)
787
+ watcher.start()
788
+ if github_data.enabled and not github_auto_sync.is_running():
789
+ github_auto_sync.change_interval(seconds=GITHUB_AUTO_SYNC_SECONDS)
790
+ github_auto_sync.start()
791
+
792
+
793
+ @client.event
794
+ async def on_disconnect():
795
+ runtime["discord_connected"] = False
796
+
797
+
798
+
799
+
800
+ @tree.command(name="help", description="Explain the tracker commands, alerts, and GitHub persistence")
801
+ async def help_cmd(interaction: discord.Interaction):
802
+ e = discord.Embed(
803
+ title="Dev Stalker — Help",
804
+ description=(
805
+ "Tracks public Rocket League Tracker activity for selected accounts. "
806
+ "Tracker data can lag, so freshness is an inference rather than direct online/queue status."
807
+ ),
808
+ color=discord.Color.blurple(),
809
+ )
810
+ e.add_field(
811
+ name="Tracking",
812
+ value=(
813
+ "`/track_add` — add an Epic/Steam/Xbox/PSN account and alert this channel.\n"
814
+ "`/track_remove` — stop tracking an account.\n"
815
+ "`/track_list` — list accounts tracked in this server.\n"
816
+ "`/rl_refresh` — force an immediate Tracker refresh for all tracked accounts."
817
+ ),
818
+ inline=False,
819
+ )
820
+ e.add_field(
821
+ name="Player info",
822
+ value=(
823
+ "`/rl_player` — current profile, ranks/MMR, and latest observed activity.\n"
824
+ "`/rl_recent` — recent recorded matches/activity with local Discord timestamps.\n"
825
+ "`/activity_graph` — graph the hours/day patterns for one tracked target or all tracked targets over a time range.\n"
826
+ "`/watch_status` — compact playlist/MMR/freshness view for one target.\n"
827
+ "`/watch_hot` — rank tracked targets by freshest observed activity."
828
+ ),
829
+ inline=False,
830
+ )
831
+ e.add_field(
832
+ name="GitHub persistence",
833
+ value=(
834
+ "GitHub backup is automatic. Adds/removes/new activity trigger immediate syncs, and a "
835
+ f"background safety sync runs about every **{GITHUB_AUTO_SYNC_SECONDS // 60 or 1} minute(s)**.\n"
836
+ "`/github_status` — show persistence health and last successful sync.\n"
837
+ "`/github_sync` — manually force a backup if you suspect something went wrong."
838
+ ),
839
+ inline=False,
840
+ )
841
+ e.add_field(
842
+ name="Alert behavior",
843
+ value=(
844
+ "Historical matches are seeded silently when a player is first added. Grouped `Multiple` rows are "
845
+ "kept in history but suppressed from live alerts. Discord timestamps render in each viewer's local timezone."
846
+ ),
847
+ inline=False,
848
+ )
849
+ e.set_footer(text="Use /github_status if persistence ever looks unhealthy.")
850
+ await interaction.response.send_message(embed=e, ephemeral=True)
851
+
852
+
853
+ @tree.command(name="track_add", description="Track a Rocket League account and alert this channel on new activity")
854
+ @app_commands.describe(platform="steam, epic, xbox, or psn", identifier="Tracker-compatible account identifier", label="Short name shown by the bot")
855
+ async def track_add(interaction: discord.Interaction, platform: str, identifier: str, label: Optional[str] = None):
856
+ if not interaction.guild_id or not interaction.channel_id:
857
+ await interaction.response.send_message("Use this command inside a server channel.", ephemeral=True)
858
+ return
859
+ platform = platform.strip().lower()
860
+ aliases = {"xbox": "xbl", "playstation": "psn", "epicgames": "epic"}
861
+ platform = aliases.get(platform, platform)
862
+ if platform not in {"steam", "epic", "xbl", "psn"}:
863
+ await interaction.response.send_message("Platform must be `steam`, `epic`, `xbl`, or `psn`.", ephemeral=True)
864
+ return
865
+
866
+ await interaction.response.defer(ephemeral=True)
867
+ row_id = store.add_player(interaction.guild_id, label or identifier, platform, identifier.strip(), interaction.channel_id)
868
+ row = store.get_player(interaction.guild_id, label or identifier)
869
+ if not row:
870
+ # If label changed on an existing record, fetch by identifier.
871
+ row = store.get_player(interaction.guild_id, identifier.strip())
872
+ try:
873
+ profile, _ = await fetch_and_store(row, seed=True)
874
+ except TrackerError as exc:
875
+ store.remove_player(interaction.guild_id, identifier.strip())
876
+ await interaction.followup.send(f"Could not load that Tracker profile: `{exc}`", ephemeral=True)
877
+ return
878
+
879
+ await github_push("Track add")
880
+ await interaction.followup.send(
881
+ f"Tracking **{profile.handle}** as **{label or profile.handle}**. New activity will be posted in <#{interaction.channel_id}>. Historical sessions were seeded without alerts.",
882
+ ephemeral=True,
883
+ )
884
+
885
+
886
+ @tree.command(name="track_remove", description="Stop tracking a Rocket League account")
887
+ async def track_remove(interaction: discord.Interaction, player: str):
888
+ if not interaction.guild_id:
889
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
890
+ return
891
+ ok = store.remove_player(interaction.guild_id, player.strip())
892
+ if ok:
893
+ await github_push("Track remove")
894
+ await interaction.response.send_message("Removed." if ok else "No matching tracked player.", ephemeral=True)
895
+
896
+
897
+ @tree.command(name="track_list", description="List Rocket League accounts tracked in this server")
898
+ async def track_list(interaction: discord.Interaction):
899
+ if not interaction.guild_id:
900
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
901
+ return
902
+ rows = store.players(interaction.guild_id)
903
+ if not rows:
904
+ await interaction.response.send_message("No players are being tracked yet.", ephemeral=True)
905
+ return
906
+ lines = []
907
+ for r in rows:
908
+ last = store.latest_match(r["id"])
909
+ status = activity_label(last["collected_at"]) if last else "UNKNOWN"
910
+ mode = last["playlist"] if last else "no session data"
911
+ lines.append(f"• **{r['label']}** — `{r['platform']}` / `{r['identifier']}` — **{status}** — {mode}")
912
+ await interaction.response.send_message("\n".join(lines), ephemeral=True)
913
+
914
+
915
+ @tree.command(name="rl_player", description="Show current MMRs and recent activity for a tracked player")
916
+ async def rl_player(interaction: discord.Interaction, player: str):
917
+ if not interaction.guild_id:
918
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
919
+ return
920
+ row = store.get_player(interaction.guild_id, player.strip())
921
+ if not row:
922
+ await interaction.response.send_message("Player not found. Use `/track_list`.", ephemeral=True)
923
+ return
924
+ await interaction.response.defer()
925
+ try:
926
+ profile, _ = await fetch_and_store(row, seed=False)
927
+ except TrackerError as exc:
928
+ await interaction.followup.send(f"Tracker request failed: `{exc}`")
929
+ return
930
+ latest = store.latest_match(row["id"])
931
+ await interaction.followup.send(embed=profile_embed(row, profile, latest))
932
+
933
+
934
+ @tree.command(name="rl_recent", description="Show a tracked player's recent recorded matches/activity")
935
+ @app_commands.describe(count="Number of entries to show (1-10)")
936
+ async def rl_recent(interaction: discord.Interaction, player: str, count: app_commands.Range[int, 1, 10] = 5):
937
+ if not interaction.guild_id:
938
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
939
+ return
940
+ row = store.get_player(interaction.guild_id, player.strip())
941
+ if not row:
942
+ await interaction.response.send_message("Player not found. Use `/track_list`.", ephemeral=True)
943
+ return
944
+ matches = store.recent_matches(row["id"], count)
945
+ if not matches:
946
+ await interaction.response.send_message("No session records stored yet.")
947
+ return
948
+ lines = []
949
+ for m in matches:
950
+ mmr = "?" if m["rating"] is None else str(m["rating"])
951
+ if m["rating_delta"] is not None:
952
+ mmr += f" ({m['rating_delta']:+d})"
953
+ lines.append(
954
+ f"**{m['playlist'] or 'Unknown'}** — {m['result'] or 'activity'} — `{mmr} MMR` — "
955
+ f"{discord_time(m['collected_at'], 'R')} ({discord_time(m['collected_at'], 't')})"
956
+ )
957
+ e = discord.Embed(title=f"Recent activity — {row['label']}", description="\n".join(lines), color=discord.Color.blurple())
958
+ await interaction.response.send_message(embed=e)
959
+
960
+
961
+ @tree.command(name="rl_refresh", description="Refresh all tracked players in this server now")
962
+ async def rl_refresh(interaction: discord.Interaction):
963
+ if not interaction.guild_id:
964
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
965
+ return
966
+ await interaction.response.defer(ephemeral=True)
967
+ rows = store.players(interaction.guild_id)
968
+ ok = 0
969
+ errors = []
970
+ for row in rows:
971
+ try:
972
+ await fetch_and_store(row, seed=False)
973
+ ok += 1
974
+ except TrackerError as exc:
975
+ errors.append(f"{row['label']}: {exc}")
976
+ if ok:
977
+ await github_push("Manual refresh")
978
+ msg = f"Refreshed {ok}/{len(rows)} tracked players."
979
+ if errors:
980
+ msg += "\n" + "\n".join(f"• {trunc(e, 160)}" for e in errors[:5])
981
+ await interaction.followup.send(msg, ephemeral=True)
982
+
983
+
984
+ @tree.command(name="watch_status", description="Show the most useful current matchmaking signal for a tracked player")
985
+ async def watch_status(interaction: discord.Interaction, player: str):
986
+ if not interaction.guild_id:
987
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
988
+ return
989
+ row = store.get_player(interaction.guild_id, player.strip())
990
+ if not row:
991
+ await interaction.response.send_message("Player not found. Use `/track_list`.", ephemeral=True)
992
+ return
993
+ latest = store.latest_match(row["id"])
994
+ if not latest:
995
+ await interaction.response.send_message("No activity has been recorded for that player yet.")
996
+ return
997
+ rating = "?" if latest["rating"] is None else str(latest["rating"])
998
+ if latest["rating_delta"] is not None:
999
+ rating += f" ({latest['rating_delta']:+d})"
1000
+ e = discord.Embed(title=f"Watch status — {row['label']}", color=discord.Color.blurple())
1001
+ e.add_field(name="Playlist", value=latest["playlist"] or "Unknown", inline=True)
1002
+ e.add_field(name="MMR", value=rating, inline=True)
1003
+ e.add_field(name="Freshness", value=activity_label(latest["collected_at"]), inline=True)
1004
+ e.add_field(name="Observed", value=f"{discord_time(latest['collected_at'],'R')}\n{discord_time(latest['collected_at'],'f')}", inline=False)
1005
+ e.add_field(name="Matchmaking signal", value=queue_window_text(latest["collected_at"]), inline=False)
1006
+ e.set_footer(text="Tracker collection times can lag and do not prove the player is currently queued.")
1007
+ await interaction.response.send_message(embed=e)
1008
+
1009
+
1010
+ @tree.command(name="watch_hot", description="List tracked players with the freshest recent activity")
1011
+ async def watch_hot(interaction: discord.Interaction):
1012
+ if not interaction.guild_id:
1013
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
1014
+ return
1015
+ entries=[]
1016
+ for row in store.players(interaction.guild_id):
1017
+ last=store.latest_match(row["id"])
1018
+ if not last:
1019
+ continue
1020
+ dt=parse_dt(last["collected_at"])
1021
+ if not dt:
1022
+ continue
1023
+ entries.append((dt,row,last))
1024
+ if not entries:
1025
+ await interaction.response.send_message("No tracked activity is stored yet.", ephemeral=True)
1026
+ return
1027
+ entries.sort(key=lambda x:x[0], reverse=True)
1028
+ lines=[]
1029
+ for _,row,last in entries[:15]:
1030
+ mmr="?" if last["rating"] is None else str(last["rating"])
1031
+ lines.append(f"**{row['label']}** — {last['playlist'] or 'Unknown'} — `{mmr} MMR` — **{activity_label(last['collected_at'])}** — {discord_time(last['collected_at'],'R')}")
1032
+ await interaction.response.send_message(embed=discord.Embed(title="Freshest tracked activity",description="\n".join(lines),color=discord.Color.blurple()))
1033
+
1034
+
1035
+ @tree.command(name="activity_graph", description="Graph when a tracked player or all tracked players are most active")
1036
+ @app_commands.describe(player="Tracked player label/handle/identifier, or leave empty to graph all tracked players", range_days="Trailing days to analyze (1-365)", style="Recommended graph style")
1037
+ @app_commands.choices(style=[
1038
+ app_commands.Choice(name="Hourly bar chart (recommended)", value="bar"),
1039
+ app_commands.Choice(name="Day × hour heatmap", value="heatmap"),
1040
+ ])
1041
+ async def activity_graph(
1042
+ interaction: discord.Interaction,
1043
+ player: Optional[str] = None,
1044
+ range_days: app_commands.Range[int, 1, 365] = 30,
1045
+ style: app_commands.Choice[str] | None = None,
1046
+ ):
1047
+ if not interaction.guild_id:
1048
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
1049
+ return
1050
+ await interaction.response.defer()
1051
+ since_dt = datetime.now(timezone.utc) - timedelta(days=int(range_days))
1052
+
1053
+ selected_label = None
1054
+ matches = []
1055
+ tracked_count = 0
1056
+
1057
+ if player and player.strip():
1058
+ row = store.get_player(interaction.guild_id, player.strip())
1059
+ if not row:
1060
+ await interaction.followup.send("Player not found. Use `/track_list`.")
1061
+ return
1062
+ selected_label = row["label"]
1063
+ matches = store.matches_since(row["id"], since_dt.isoformat())
1064
+ tracked_count = 1
1065
+ else:
1066
+ rows = store.players(interaction.guild_id)
1067
+ tracked_count = len(rows)
1068
+ if not rows:
1069
+ await interaction.followup.send("No players are being tracked yet.")
1070
+ return
1071
+ selected_label = f"All tracked players ({tracked_count})"
1072
+ for row in rows:
1073
+ matches.extend(store.matches_since(row["id"], since_dt.isoformat()))
1074
+
1075
+ if not matches:
1076
+ await interaction.followup.send("No stored activity falls inside that time range yet.")
1077
+ return
1078
+
1079
+ chosen_style = style.value if style else "bar"
1080
+ try:
1081
+ image_buf, stats, caption = build_activity_graph(matches, selected_label, int(range_days), chosen_style)
1082
+ except ValueError as exc:
1083
+ await interaction.followup.send(str(exc))
1084
+ return
1085
+
1086
+ safe_name = selected_label.replace(' ', '_').replace('(', '').replace(')', '').replace('/', '_')
1087
+ filename = f"activity_{safe_name}_{chosen_style}.png"
1088
+ file = discord.File(image_buf, filename=filename)
1089
+ embed = discord.Embed(
1090
+ title=f"Activity graph — {selected_label}",
1091
+ description=(
1092
+ f"Observed public Tracker activity over the last **{int(range_days)} day(s)**. "
1093
+ "Use this as a tendency signal, not exact online status."
1094
+ ),
1095
+ color=discord.Color.blurple(),
1096
+ )
1097
+ embed.add_field(name="Scope", value=selected_label, inline=False)
1098
+ embed.add_field(name="Top hour (UTC)", value=f"`{stats['top_hour']:02d}:00–{(stats['top_hour'] + 1) % 24:02d}:00` • {stats['top_hour_count']} matches", inline=True)
1099
+ embed.add_field(name="Most active day", value=f"**{stats['top_day']}** • {stats['top_day_count']} matches", inline=True)
1100
+ embed.add_field(name="Top playlist", value=f"**{stats['top_playlist']}** • {stats['top_playlist_count']} matches", inline=True)
1101
+ embed.add_field(name="Observed matches", value=str(stats['match_count']), inline=True)
1102
+ embed.add_field(name="Active days", value=str(stats['active_days']), inline=True)
1103
+ embed.add_field(name="Avg / active day", value=f"{stats['avg_per_active_day']:.2f}", inline=True)
1104
+ embed.set_image(url=f"attachment://{filename}")
1105
+ embed.set_footer(text=caption)
1106
+ await interaction.followup.send(embed=embed, file=file)
1107
+
1108
+
1109
+ @tree.command(name="github_sync", description="Manually sync the bot datasets to GitHub")
1110
+ async def github_sync_cmd(interaction: discord.Interaction):
1111
+ await interaction.response.defer(ephemeral=True)
1112
+ if not github_data.enabled:
1113
+ await interaction.followup.send("GitHub persistence is disabled. Add `GHT` as a Hugging Face secret.", ephemeral=True)
1114
+ return
1115
+ ok=await github_push("Manual Discord sync")
1116
+ if ok:
1117
+ await interaction.followup.send(f"Synced datasets to `{GITHUB_DATA_REPO}`.", ephemeral=True)
1118
+ else:
1119
+ await interaction.followup.send(f"GitHub sync failed: `{trunc(github_data.last_error or 'unknown error',300)}`", ephemeral=True)
1120
+
1121
+
1122
+ @tree.command(name="github_status", description="Show GitHub persistence status")
1123
+ async def github_status_cmd(interaction: discord.Interaction):
1124
+ state="enabled" if github_data.enabled else "disabled"
1125
+ msg=(f"GitHub persistence: **{state}**\nRepo: `{GITHUB_DATA_REPO}`\nBranch: `{GITHUB_DATA_BRANCH}`\n"
1126
+ f"Last sync: {discord_time(github_data.last_sync_at,'R') if github_data.last_sync_at else 'never'}")
1127
+ if github_data.last_error:
1128
+ msg += f"\nLast error: `{trunc(github_data.last_error,300)}`"
1129
+ await interaction.response.send_message(msg, ephemeral=True)
1130
+
1131
+
1132
+ @tasks.loop(seconds=300)
1133
+ async def github_auto_sync():
1134
+ if not github_data.enabled:
1135
+ return
1136
+ ok = await github_push("Automatic safety sync")
1137
+ if ok:
1138
+ log.info("Automatic GitHub dataset sync completed")
1139
+
1140
+
1141
+ @github_auto_sync.before_loop
1142
+ async def before_github_auto_sync():
1143
+ await client.wait_until_ready()
1144
+
1145
+
1146
+ @tasks.loop(seconds=120)
1147
+ async def watcher():
1148
+ rows = store.players()
1149
+ for row in rows:
1150
+ try:
1151
+ profile, new_matches = await fetch_and_store(row, seed=False)
1152
+ if not new_matches:
1153
+ continue
1154
+
1155
+ watermark_raw = store.ensure_alert_watermark(row["id"])
1156
+ watermark = parse_dt(watermark_raw)
1157
+ now = datetime.now(timezone.utc)
1158
+ filtered: list[dict[str, Any]] = []
1159
+ for match in new_matches:
1160
+ collected = parse_dt(match.get("collected_at"))
1161
+ if not collected:
1162
+ continue
1163
+ # Grouped/aggregate Tracker rows such as "Multiple" can be backfilled later and
1164
+ # are not reliable enough for live matchmaking alerts. Keep them in history only.
1165
+ if match.get("is_grouped"):
1166
+ continue
1167
+ if watermark and collected <= watermark:
1168
+ continue
1169
+ if (now - collected).total_seconds() > ALERT_MAX_AGE_MINUTES * 60:
1170
+ continue
1171
+ filtered.append(match)
1172
+
1173
+ if not filtered:
1174
+ continue
1175
+
1176
+ filtered.sort(key=lambda m: m.get("collected_at") or "", reverse=True)
1177
+ channel = client.get_channel(row["alert_channel_id"])
1178
+ if channel is None:
1179
+ try:
1180
+ channel = await client.fetch_channel(row["alert_channel_id"])
1181
+ except discord.DiscordException:
1182
+ log.warning("Could not resolve channel %s", row["alert_channel_id"])
1183
+ continue
1184
+ sent_any = False
1185
+ for match in filtered[:3]:
1186
+ try:
1187
+ await channel.send(embed=match_embed(row, match))
1188
+ store.set_alert_watermark(row["id"], match.get("collected_at"))
1189
+ sent_any = True
1190
+ except discord.DiscordException as exc:
1191
+ log.warning("Failed sending alert for %s: %s", row["label"], exc)
1192
+ if sent_any:
1193
+ await github_push("New Tracker activity")
1194
+ except TrackerError as exc:
1195
+ log.warning("Tracker error for %s: %s", row["label"], exc)
1196
+ except Exception:
1197
+ log.exception("Unexpected watcher error for %s", row["label"])
1198
+ await asyncio.sleep(1)
1199
+
1200
+
1201
+ @watcher.before_loop
1202
+ async def before_watcher():
1203
+ await client.wait_until_ready()
1204
+
1205
+
1206
+ # Hugging Face Docker Spaces expect an HTTP service. This tiny endpoint also gives
1207
+ # you a convenient uptime/health check while the Discord bot runs in the same process.
1208
+ health = FastAPI(title="Rocket Watch Health")
1209
+
1210
+
1211
+ @health.get("/")
1212
+ def root():
1213
+ return {
1214
+ "ok": True,
1215
+ "service": "rocket-watch-discord-bot",
1216
+ "discord_connected": runtime["discord_connected"],
1217
+ "discord_user": runtime["discord_user"],
1218
+ "discord_last_error": runtime["discord_last_error"],
1219
+ "discord_last_attempt": runtime["discord_last_attempt"],
1220
+ "discord_last_connected": runtime["discord_last_connected"],
1221
+ "discord_retry_seconds": runtime["discord_retry_seconds"],
1222
+ "discord_retry_max_seconds": max(10, int(os.getenv("DISCORD_RETRY_MAX_SECONDS", "30"))),
1223
+ "discord_stage": runtime["discord_stage"],
1224
+ "discord_stage_since": runtime["discord_stage_since"],
1225
+ "discord_relay_enabled": bool(DISCORD_RELAY_GATEWAY),
1226
+ "discord_relay_origin": DISCORD_RELAY_ORIGIN or None,
1227
+ "tracked_players": len(store.players()),
1228
+ "poll_seconds": POLL_SECONDS,
1229
+ "alert_max_age_minutes": ALERT_MAX_AGE_MINUTES,
1230
+ "github_persistence_enabled": github_data.enabled,
1231
+ "github_data_repo": GITHUB_DATA_REPO,
1232
+ "github_last_sync_at": github_data.last_sync_at,
1233
+ "github_auto_sync_seconds": GITHUB_AUTO_SYNC_SECONDS,
1234
+ "github_last_error": github_data.last_error,
1235
+ }
1236
+
1237
+
1238
+ @health.get("/health")
1239
+ def healthcheck():
1240
+ return {
1241
+ "ok": True,
1242
+ "http": "online",
1243
+ "discord": "connected" if runtime["discord_connected"] else "disconnected",
1244
+ "discord_last_error": runtime["discord_last_error"],
1245
+ "discord_stage": runtime["discord_stage"],
1246
+ "discord_stage_since": runtime["discord_stage_since"],
1247
+ }
1248
+
1249
+
1250
+ def set_discord_stage(stage: str) -> None:
1251
+ runtime["discord_stage"] = stage
1252
+ runtime["discord_stage_since"] = utc_now_iso()
1253
+
1254
+
1255
+ @health.get("/debug/discord")
1256
+ async def debug_discord():
1257
+ """Test both direct Discord and the configured Deno relay without a token."""
1258
+
1259
+ async def probe(label: str, url: str):
1260
+ started = time.monotonic()
1261
+ try:
1262
+ timeout = aiohttp.ClientTimeout(total=10, connect=5, sock_connect=5, sock_read=5)
1263
+ async with aiohttp.ClientSession(timeout=timeout) as session:
1264
+ async with session.get(url) as resp:
1265
+ body = await resp.text()
1266
+ return {
1267
+ "label": label,
1268
+ "url": url.replace(DISCORD_RELAY_SECRET, "***") if DISCORD_RELAY_SECRET else url,
1269
+ "status": resp.status,
1270
+ "body": body[:300],
1271
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
1272
+ }
1273
+ except Exception as exc:
1274
+ return {
1275
+ "label": label,
1276
+ "url": url.replace(DISCORD_RELAY_SECRET, "***") if DISCORD_RELAY_SECRET else url,
1277
+ "error": f"{type(exc).__name__}: {exc}",
1278
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
1279
+ }
1280
+
1281
+ dns = None
1282
+ try:
1283
+ infos = await asyncio.wait_for(
1284
+ asyncio.get_running_loop().run_in_executor(
1285
+ None, lambda: socket.getaddrinfo("discord.com", 443, type=socket.SOCK_STREAM)
1286
+ ),
1287
+ timeout=5,
1288
+ )
1289
+ dns = sorted({item[4][0] for item in infos})[:6]
1290
+ except Exception as exc:
1291
+ dns = f"{type(exc).__name__}: {exc}"
1292
+
1293
+ result = {
1294
+ "discord_dns": dns,
1295
+ "direct": await probe("direct", "https://discord.com/api/v10/gateway"),
1296
+ "relay_enabled": bool(DISCORD_RELAY_GATEWAY),
1297
+ }
1298
+ if DISCORD_RELAY_GATEWAY:
1299
+ relay_url = f"{DISCORD_RELAY_ORIGIN}/p/{DISCORD_RELAY_SECRET}/api/v10/gateway"
1300
+ result["relay"] = await probe("deno-relay", relay_url)
1301
+ return result
1302
+
1303
+
1304
+ async def sync_commands():
1305
+ """Sync slash commands after Discord is actually connected."""
1306
+ try:
1307
+ if GUILD_ID:
1308
+ guild = discord.Object(id=int(GUILD_ID))
1309
+ tree.copy_global_to(guild=guild)
1310
+ synced = await tree.sync(guild=guild)
1311
+ log.info("Synced %d slash commands to guild %s", len(synced), GUILD_ID)
1312
+ else:
1313
+ synced = await tree.sync()
1314
+ log.info("Synced %d global slash commands", len(synced))
1315
+ except Exception:
1316
+ log.exception("Slash-command sync failed")
1317
+
1318
+
1319
+ def rebuild_discord_client() -> None:
1320
+ """Replace the Discord client with a brand-new HTTP/WebSocket stack.
1321
+
1322
+ discord.py can close its internal aiohttp session after a failed login.
1323
+ Client.clear() resets cache/state but does not make that closed session usable
1324
+ again, so retries must use a fresh Client instance.
1325
+ """
1326
+ global client, tree
1327
+
1328
+ # Preserve the application-command objects already registered on the current
1329
+ # tree, then attach them to a fresh tree/client.
1330
+ commands = list(tree.get_commands())
1331
+
1332
+ new_client = discord.Client(intents=intents)
1333
+ new_tree = app_commands.CommandTree(new_client)
1334
+ for command in commands:
1335
+ new_tree.add_command(command)
1336
+
1337
+ # Re-register event callbacks on the new Client. The callbacks intentionally
1338
+ # reference the module-level `client`, which is rebound below.
1339
+ new_client.event(on_ready)
1340
+ new_client.event(on_disconnect)
1341
+
1342
+ client = new_client
1343
+ tree = new_tree
1344
+
1345
+
1346
+ async def discord_supervisor():
1347
+ """Keep Discord failures isolated from the Hugging Face HTTP service.
1348
+
1349
+ The Space listens only on $PORT (7860 by default). Discord uses outbound
1350
+ HTTPS/WSS and therefore does not need another local listening port. Every
1351
+ retry uses a completely fresh discord.Client so a failed aiohttp session
1352
+ cannot poison future attempts.
1353
+ """
1354
+ delay = 3
1355
+ # Keep recovery quick. discord.py's own reconnect loop can grow to very long
1356
+ # waits after repeated relay 503s, so this supervisor owns reconnects instead.
1357
+ max_delay = max(10, int(os.getenv("DISCORD_RETRY_MAX_SECONDS", "30")))
1358
+ first_attempt = True
1359
+
1360
+ while True:
1361
+ runtime["discord_last_attempt"] = utc_now_iso()
1362
+ runtime["discord_retry_seconds"] = None
1363
+
1364
+ if not first_attempt:
1365
+ rebuild_discord_client()
1366
+ first_attempt = False
1367
+
1368
+ connect_task = None
1369
+ try:
1370
+ log.info("Connecting to Discord with a fresh client...")
1371
+
1372
+ set_discord_stage("rest_login")
1373
+ await asyncio.wait_for(
1374
+ client.login(DISCORD_BOT_TOKEN),
1375
+ timeout=DISCORD_LOGIN_TIMEOUT,
1376
+ )
1377
+ log.info("Discord REST login succeeded as %s", client.user)
1378
+
1379
+ set_discord_stage("command_sync")
1380
+ await asyncio.wait_for(sync_commands(), timeout=20)
1381
+
1382
+ # client.connect() is intentionally long-running. Start it separately,
1383
+ # then require READY within a bounded amount of time. This distinguishes
1384
+ # a successful REST login from a Gateway/WebSocket failure.
1385
+ set_discord_stage("gateway_connect")
1386
+ # Important: do NOT let discord.py own the reconnect loop here. Its
1387
+ # exponential backoff can grow to many minutes if the relay returns
1388
+ # repeated 503s after a Deno Deploy eviction. A single gateway failure
1389
+ # should return control to this supervisor so it can rebuild the entire
1390
+ # Client/HTTP session and retry with a small capped delay.
1391
+ connect_task = asyncio.create_task(client.connect(reconnect=False), name="discord-gateway")
1392
+ await asyncio.wait_for(client.wait_until_ready(), timeout=DISCORD_READY_TIMEOUT)
1393
+ set_discord_stage("connected")
1394
+ log.info("Discord gateway is READY")
1395
+ delay = 3
1396
+
1397
+ # Stay attached until the gateway closes. With reconnect=False this
1398
+ # returns/raises immediately on a broken relay connection instead of
1399
+ # entering discord.py's long internal backoff loop.
1400
+ await connect_task
1401
+
1402
+ runtime["discord_connected"] = False
1403
+ runtime["discord_last_error"] = "Discord connection closed"
1404
+ set_discord_stage("disconnected")
1405
+ delay = 3
1406
+ except asyncio.CancelledError:
1407
+ try:
1408
+ if not client.is_closed():
1409
+ await client.close()
1410
+ finally:
1411
+ raise
1412
+ except asyncio.TimeoutError:
1413
+ stage = runtime.get("discord_stage") or "unknown"
1414
+ timeout_used = DISCORD_LOGIN_TIMEOUT if stage == "rest_login" else DISCORD_READY_TIMEOUT
1415
+ runtime["discord_connected"] = False
1416
+ runtime["discord_last_error"] = f"Timeout while in {stage} after {timeout_used}s"
1417
+ log.error("Discord timed out during %s after %ss", stage, timeout_used)
1418
+ except discord.LoginFailure as exc:
1419
+ runtime["discord_connected"] = False
1420
+ runtime["discord_last_error"] = f"Login failure: {exc}"
1421
+ delay = 300
1422
+ log.error("Discord rejected the bot token: %s", exc)
1423
+ except Exception as exc:
1424
+ runtime["discord_connected"] = False
1425
+ runtime["discord_last_error"] = f"{type(exc).__name__}: {exc}"
1426
+ log.exception("Discord connection attempt failed")
1427
+
1428
+ # Best-effort cleanup. We never reuse this instance after a failure.
1429
+ if connect_task is not None and not connect_task.done():
1430
+ connect_task.cancel()
1431
+ try:
1432
+ await connect_task
1433
+ except (asyncio.CancelledError, Exception):
1434
+ pass
1435
+ try:
1436
+ if not client.is_closed():
1437
+ await client.close()
1438
+ except Exception:
1439
+ log.debug("Discord client cleanup failed", exc_info=True)
1440
+
1441
+ runtime["discord_retry_seconds"] = delay
1442
+ set_discord_stage("retry_wait")
1443
+ log.warning("Discord unavailable; retrying with a fresh client in %ss", delay)
1444
+ await asyncio.sleep(delay)
1445
+ delay = min(delay * 2, max_delay)
1446
+
1447
+
1448
+ async def main():
1449
+ if not DISCORD_BOT_TOKEN:
1450
+ raise RuntimeError(
1451
+ "DISCORD_BOT_TOKEN is not set. Add it as a Hugging Face Space secret or environment variable."
1452
+ )
1453
+
1454
+ if github_data.enabled:
1455
+ try:
1456
+ await asyncio.to_thread(github_data.ensure_files)
1457
+ restored = await asyncio.to_thread(github_data.restore_into, store)
1458
+ log.info("Restored %s players and %s activity records from GitHub", restored["players"], restored["matches"])
1459
+ await github_push("Startup reconciliation")
1460
+ except Exception as exc:
1461
+ github_data.last_error = f"{type(exc).__name__}: {exc}"
1462
+ log.warning("GitHub startup restore failed: %s", exc)
1463
+
1464
+ # Hugging Face exposes one HTTP port. Uvicorn owns that port; Discord runs as
1465
+ # an outbound background connection and never binds a local port.
1466
+ port = int(os.getenv("PORT", "7860"))
1467
+ config = uvicorn.Config(
1468
+ health,
1469
+ host="0.0.0.0",
1470
+ port=port,
1471
+ log_level=os.getenv("UVICORN_LOG_LEVEL", "info").lower(),
1472
+ lifespan="on",
1473
+ )
1474
+ server = uvicorn.Server(config)
1475
+
1476
+ discord_task = asyncio.create_task(discord_supervisor(), name="discord-supervisor")
1477
+ try:
1478
+ await server.serve()
1479
+ finally:
1480
+ discord_task.cancel()
1481
+ try:
1482
+ await discord_task
1483
+ except asyncio.CancelledError:
1484
+ pass
1485
+ if not client.is_closed():
1486
+ await client.close()
1487
+
1488
+
1489
+ if __name__ == "__main__":
1490
+ asyncio.run(main())
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ discord.py>=2.5,<3
2
+ requests>=2.32,<3
3
+ fastapi>=0.115,<1
4
+ uvicorn[standard]>=0.34,<1
5
+ matplotlib>=3.9,<4
tracker_client.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import requests
9
+
10
+ BASE_WEB = "https://api.tracker.gg"
11
+ PROFILE_WEB = "https://rocketleague.tracker.network/rocket-league/profile"
12
+
13
+
14
+ class TrackerError(RuntimeError):
15
+ pass
16
+
17
+
18
+ def _dt(value: Optional[str]) -> Optional[datetime]:
19
+ if not value:
20
+ return None
21
+ try:
22
+ d = datetime.fromisoformat(value.replace("Z", "+00:00"))
23
+ if d.tzinfo is None:
24
+ d = d.replace(tzinfo=timezone.utc)
25
+ return d.astimezone(timezone.utc)
26
+ except ValueError:
27
+ return None
28
+
29
+
30
+ def _stat_value(stats: Dict[str, Any], name: str, default=None):
31
+ return (stats.get(name) or {}).get("value", default)
32
+
33
+
34
+ @dataclass
35
+ class PlayerProfile:
36
+ platform: str
37
+ identifier: str
38
+ handle: str
39
+ player_id: Optional[int]
40
+ last_updated: Optional[datetime]
41
+ season: Optional[int]
42
+ avatar_url: Optional[str]
43
+ lifetime: Dict[str, Any]
44
+ playlists: List[Dict[str, Any]]
45
+ raw: Dict[str, Any]
46
+
47
+
48
+ class TrackerClient:
49
+ """Best-effort client for Tracker web endpoints observed in supplied samples.
50
+
51
+ This intentionally does not solve/bypass Cloudflare or Warden challenges. A 401/403
52
+ is returned to the bot as an error instead.
53
+ """
54
+
55
+ def __init__(self, timeout: int = 20):
56
+ self.session = requests.Session()
57
+ self.timeout = timeout
58
+ self.session.headers.update(
59
+ {
60
+ "Accept": "application/json, text/plain, */*",
61
+ "Accept-Language": "en-US,en;q=0.9",
62
+ "Origin": "https://rocketleague.tracker.network",
63
+ "Referer": "https://rocketleague.tracker.network/",
64
+ "User-Agent": os.getenv(
65
+ "TRACKER_USER_AGENT",
66
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
67
+ "(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
68
+ ),
69
+ }
70
+ )
71
+ cookie = os.getenv("TRACKER_COOKIE", "").strip()
72
+ if cookie:
73
+ self.session.headers["Cookie"] = cookie
74
+
75
+ def _prime_profile_session(self, platform: str, identifier: str) -> None:
76
+ """Visit the normal public Tracker profile page first so browser cookies/state exist.
77
+
78
+ This does not solve CAPTCHA/WAF challenges. It only mirrors the normal browser flow
79
+ of opening a profile page before its JSON requests are made.
80
+ """
81
+ page_url = f"{PROFILE_WEB}/{platform}/{identifier}"
82
+ headers = {
83
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
84
+ "Sec-Fetch-Dest": "document",
85
+ "Sec-Fetch-Mode": "navigate",
86
+ "Sec-Fetch-Site": "none",
87
+ "Upgrade-Insecure-Requests": "1",
88
+ }
89
+ try:
90
+ self.session.get(page_url, headers=headers, timeout=self.timeout, allow_redirects=True)
91
+ except requests.RequestException:
92
+ # The API request below is still worth attempting; it will produce the useful error.
93
+ pass
94
+
95
+ @staticmethod
96
+ def _extract_profile_from_path(path: str) -> tuple[Optional[str], Optional[str]]:
97
+ marker = "/api/v2/rocket-league/standard/profile/"
98
+ if not path.startswith(marker):
99
+ return None, None
100
+ rest = path[len(marker):].strip("/")
101
+ parts = rest.split("/")
102
+ if len(parts) < 2:
103
+ return None, None
104
+ return parts[0], parts[1]
105
+
106
+ def _get(self, path: str, params: Optional[dict] = None) -> Dict[str, Any]:
107
+ url = f"{BASE_WEB}{path}"
108
+
109
+ platform, identifier = self._extract_profile_from_path(path)
110
+ if platform and identifier:
111
+ self._prime_profile_session(platform, identifier)
112
+
113
+ api_headers = {
114
+ "Accept": "application/json, text/plain, */*",
115
+ "Referer": (
116
+ f"{PROFILE_WEB}/{platform}/{identifier}"
117
+ if platform and identifier
118
+ else "https://rocketleague.tracker.network/"
119
+ ),
120
+ "Sec-Fetch-Dest": "empty",
121
+ "Sec-Fetch-Mode": "cors",
122
+ "Sec-Fetch-Site": "same-site",
123
+ }
124
+
125
+ try:
126
+ r = self.session.get(url, params=params, headers=api_headers, timeout=self.timeout)
127
+ except requests.RequestException as exc:
128
+ raise TrackerError(f"Network error contacting Tracker: {exc}") from exc
129
+ if r.status_code in (401, 403):
130
+ preview = " ".join((r.text or "").split())[:240]
131
+ server = r.headers.get("server", "unknown")
132
+ raise TrackerError(
133
+ f"HTTP {r.status_code} from Tracker (server={server}). "
134
+ f"Response preview: {preview or '<empty>'}"
135
+ )
136
+ try:
137
+ r.raise_for_status()
138
+ except requests.HTTPError as exc:
139
+ raise TrackerError(f"HTTP {r.status_code} for {r.url}") from exc
140
+ try:
141
+ return r.json()
142
+ except ValueError as exc:
143
+ raise TrackerError("Tracker returned non-JSON content") from exc
144
+
145
+ def profile(self, platform: str, identifier: str) -> PlayerProfile:
146
+ payload = self._get(f"/api/v2/rocket-league/standard/profile/{platform}/{identifier}")
147
+ data = payload.get("data") or {}
148
+ pinfo = data.get("platformInfo") or {}
149
+ meta = data.get("metadata") or {}
150
+ segments = data.get("segments") or []
151
+
152
+ lifetime: Dict[str, Any] = {}
153
+ playlists: List[Dict[str, Any]] = []
154
+ for seg in segments:
155
+ stats = seg.get("stats") or {}
156
+ if seg.get("type") == "overview":
157
+ lifetime = {k: (v or {}).get("value") for k, v in stats.items()}
158
+ elif seg.get("type") == "playlist":
159
+ attrs = seg.get("attributes") or {}
160
+ smeta = seg.get("metadata") or {}
161
+ tier = stats.get("tier") or {}
162
+ div = stats.get("division") or {}
163
+ rating = stats.get("rating") or {}
164
+ peak = stats.get("peakRating") or {}
165
+ playlists.append(
166
+ {
167
+ "playlist_id": attrs.get("playlistId"),
168
+ "name": smeta.get("name", f"Playlist {attrs.get('playlistId')}"),
169
+ "season": attrs.get("season"),
170
+ "rating": rating.get("value"),
171
+ "tier": (rating.get("metadata") or {}).get("tierName")
172
+ or (tier.get("metadata") or {}).get("name"),
173
+ "division": (div.get("metadata") or {}).get("name"),
174
+ "matches_played": _stat_value(stats, "matchesPlayed"),
175
+ "win_streak": _stat_value(stats, "winStreak"),
176
+ "peak_rating": peak.get("value"),
177
+ }
178
+ )
179
+
180
+ return PlayerProfile(
181
+ platform=pinfo.get("platformSlug") or platform,
182
+ identifier=pinfo.get("platformUserIdentifier") or identifier,
183
+ handle=pinfo.get("platformUserHandle") or identifier,
184
+ player_id=meta.get("playerId"),
185
+ last_updated=_dt((meta.get("lastUpdated") or {}).get("value")),
186
+ season=meta.get("currentSeason"),
187
+ avatar_url=pinfo.get("avatarUrl"),
188
+ lifetime=lifetime,
189
+ playlists=playlists,
190
+ raw=payload,
191
+ )
192
+
193
+ def sessions(self, platform: str, identifier: str) -> Dict[str, Any]:
194
+ return self._get(f"/api/v2/rocket-league/standard/profile/{platform}/{identifier}/sessions")
195
+
196
+ def mmr_history(self, player_id: int) -> Dict[str, Any]:
197
+ return self._get(f"/api/v1/rocket-league/player-history/mmr/{player_id}")
198
+
199
+ def overview_history(self, player_id: int) -> Dict[str, Any]:
200
+ return self._get(f"/api/v1/rocket-league/player-history/overview/{player_id}", params={"type": "graph"})
201
+
202
+ def aggregated(self, platform: str, identifier: str, local_offset: int = 0) -> Dict[str, Any]:
203
+ return self._get(
204
+ f"/api/v1/rocket-league/matches/{platform}/{identifier}/aggregated",
205
+ params={"localOffset": local_offset},
206
+ )
207
+
208
+ @staticmethod
209
+ def flatten_session_matches(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
210
+ out: List[Dict[str, Any]] = []
211
+ for session in ((payload.get("data") or {}).get("items") or []):
212
+ for m in session.get("matches") or []:
213
+ meta = m.get("metadata") or {}
214
+ stats = m.get("stats") or {}
215
+ r = stats.get("rating") or {}
216
+ rmeta = r.get("metadata") or {}
217
+ out.append(
218
+ {
219
+ "match_id": m.get("id"),
220
+ "collected_at": meta.get("dateCollected"),
221
+ "playlist": meta.get("playlist"),
222
+ "result": meta.get("result"),
223
+ "is_grouped": bool(meta.get("isGrouped")),
224
+ "rating": r.get("value"),
225
+ "rating_delta": rmeta.get("ratingDelta"),
226
+ "tier": rmeta.get("tier"),
227
+ "division": rmeta.get("division"),
228
+ "matches_played": _stat_value(stats, "matchesPlayed"),
229
+ "wins": _stat_value(stats, "wins"),
230
+ "goals": _stat_value(stats, "goals"),
231
+ "assists": _stat_value(stats, "assists"),
232
+ "saves": _stat_value(stats, "saves"),
233
+ "shots": _stat_value(stats, "shots"),
234
+ "mvps": _stat_value(stats, "mvps"),
235
+ }
236
+ )
237
+ out.sort(key=lambda x: x.get("collected_at") or "", reverse=True)
238
+ return out