wuhp commited on
Commit
776172f
·
verified ·
1 Parent(s): 8e3e830

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 +1239 -0
  5. requirements.txt +4 -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,1239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any, Optional
14
+
15
+ import discord
16
+ from discord import app_commands
17
+ from discord.ext import tasks
18
+ from fastapi import FastAPI
19
+ import aiohttp
20
+ import uvicorn
21
+ import requests
22
+ import yarl
23
+
24
+ from tracker_client import TrackerClient, TrackerError
25
+
26
+ APP_DIR = Path(__file__).resolve().parent
27
+ DB_PATH = Path(os.getenv("DB_PATH", str(APP_DIR / "rocket_watch.db")))
28
+ POLL_SECONDS = max(60, int(os.getenv("POLL_SECONDS", "120")))
29
+ ALERT_MAX_AGE_MINUTES = max(5, int(os.getenv("ALERT_MAX_AGE_MINUTES", "30")))
30
+ DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN", "").strip()
31
+ GUILD_ID = os.getenv("DISCORD_GUILD_ID", "").strip()
32
+ DISCORD_LOGIN_TIMEOUT = max(5, int(os.getenv("DISCORD_LOGIN_TIMEOUT", "20")))
33
+ DISCORD_READY_TIMEOUT = max(10, int(os.getenv("DISCORD_READY_TIMEOUT", "30")))
34
+ DISCORD_RELAY_ORIGIN = (os.getenv("DISCORD_RELAY_ORIGIN") or os.getenv("DISCORD_PROXY_ORIGIN", "")).strip().rstrip("/")
35
+ DISCORD_RELAY_SECRET = (os.getenv("DISCORD_RELAY_SECRET") or os.getenv("DISCORD_PROXY_SECRET", "")).strip().strip("/")
36
+ GHT = os.getenv("GHT", "").strip()
37
+ GITHUB_DATA_REPO = os.getenv("GITHUB_DATA_REPO", "Wuhpondiscord/rldevs").strip()
38
+ GITHUB_DATA_BRANCH = os.getenv("GITHUB_DATA_BRANCH", "main").strip() or "main"
39
+ GITHUB_SYNC_ACTIVITY_LIMIT = max(100, int(os.getenv("GITHUB_SYNC_ACTIVITY_LIMIT", "5000")))
40
+
41
+ logging.basicConfig(
42
+ level=os.getenv("LOG_LEVEL", "INFO").upper(),
43
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
44
+ )
45
+ log = logging.getLogger("rocket-watch")
46
+
47
+
48
+ def configure_discord_relay() -> Optional[str]:
49
+ """Route discord.py REST + Gateway traffic through the external Deno relay.
50
+
51
+ The relay uses a secret path segment so it cannot be used as an open Discord
52
+ proxy by anyone who does not know the shared secret. The bot token is not
53
+ stored in the relay; discord.py sends it as the normal Authorization header.
54
+ """
55
+ if not DISCORD_RELAY_ORIGIN and not DISCORD_RELAY_SECRET:
56
+ return None
57
+ if not DISCORD_RELAY_ORIGIN or not DISCORD_RELAY_SECRET:
58
+ raise RuntimeError(
59
+ "Set both DISCORD_RELAY_ORIGIN and DISCORD_RELAY_SECRET, or neither."
60
+ )
61
+ if not DISCORD_RELAY_ORIGIN.startswith("https://"):
62
+ raise RuntimeError("DISCORD_RELAY_ORIGIN must start with https://")
63
+
64
+ from discord.http import Route
65
+ from discord.gateway import DiscordWebSocket
66
+
67
+ rest_base = f"{DISCORD_RELAY_ORIGIN}/p/{DISCORD_RELAY_SECRET}/api/v10"
68
+ gateway = DISCORD_RELAY_ORIGIN.replace("https://", "wss://", 1)
69
+ gateway = f"{gateway}/p/{DISCORD_RELAY_SECRET}/gateway"
70
+
71
+ Route.BASE = rest_base
72
+ DiscordWebSocket.DEFAULT_GATEWAY = yarl.URL(gateway)
73
+
74
+ # Discord's READY event contains a direct resume_gateway_url. discord.py stores
75
+ # that URL for later RESUME attempts. Restore our proxy URL immediately after
76
+ # READY/RESUMED processing so reconnects never escape the relay.
77
+ original_received_message = DiscordWebSocket.received_message
78
+
79
+ async def proxied_received_message(self, msg):
80
+ result = await original_received_message(self, msg)
81
+ if getattr(self, "session_id", None) and str(getattr(self, "gateway", "")) != gateway:
82
+ self.gateway = yarl.URL(gateway)
83
+ return result
84
+
85
+ if not getattr(DiscordWebSocket.received_message, "_rocket_watch_proxy_patch", False):
86
+ proxied_received_message._rocket_watch_proxy_patch = True
87
+ DiscordWebSocket.received_message = proxied_received_message
88
+
89
+ log.info("Discord relay enabled through %s", DISCORD_RELAY_ORIGIN)
90
+ return gateway
91
+
92
+
93
+ DISCORD_RELAY_GATEWAY = configure_discord_relay()
94
+
95
+
96
+ def utc_now_iso() -> str:
97
+ return datetime.now(timezone.utc).isoformat()
98
+
99
+
100
+ def parse_dt(value: Optional[str]) -> Optional[datetime]:
101
+ if not value:
102
+ return None
103
+ try:
104
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
105
+ if dt.tzinfo is None:
106
+ dt = dt.replace(tzinfo=timezone.utc)
107
+ return dt.astimezone(timezone.utc)
108
+ except ValueError:
109
+ return None
110
+
111
+
112
+ def age_text(value: Optional[str]) -> str:
113
+ dt = parse_dt(value)
114
+ if not dt:
115
+ return "unknown"
116
+ sec = max(0, int((datetime.now(timezone.utc) - dt).total_seconds()))
117
+ if sec < 60:
118
+ return f"{sec}s ago"
119
+ if sec < 3600:
120
+ return f"{sec // 60}m ago"
121
+ if sec < 86400:
122
+ return f"{sec // 3600}h {(sec % 3600) // 60}m ago"
123
+ return f"{sec // 86400}d ago"
124
+
125
+
126
+ def discord_time(value: Optional[str], style: str = "F") -> str:
127
+ """Render a timestamp in each Discord viewer's own locale/timezone."""
128
+ dt = parse_dt(value)
129
+ if not dt:
130
+ return "unknown"
131
+ return f"<t:{int(dt.timestamp())}:{style}>"
132
+
133
+
134
+ def queue_window_text(value: Optional[str]) -> str:
135
+ """Conservative inference from Tracker collection time; never claims live presence."""
136
+ dt = parse_dt(value)
137
+ if not dt:
138
+ return "Unknown"
139
+ age_min = max(0, (datetime.now(timezone.utc) - dt).total_seconds() / 60)
140
+ if age_min <= 3:
141
+ return "**Very fresh result** — strongest window to try the same playlist/MMR"
142
+ if age_min <= 8:
143
+ return "**Fresh** — they may still be playing or re-queueing"
144
+ if age_min <= 20:
145
+ return "**Recent** — useful signal, but queue status is uncertain"
146
+ return "**Stale** — weak signal for current matchmaking"
147
+
148
+
149
+ def activity_label(value: Optional[str]) -> str:
150
+ dt = parse_dt(value)
151
+ if not dt:
152
+ return "UNKNOWN"
153
+ age = (datetime.now(timezone.utc) - dt).total_seconds()
154
+ if age <= 8 * 60:
155
+ return "HOT"
156
+ if age <= 20 * 60:
157
+ return "RECENT"
158
+ if age <= 60 * 60:
159
+ return "COOLING"
160
+ return "INACTIVE"
161
+
162
+
163
+ def trunc(s: str, n: int) -> str:
164
+ return s if len(s) <= n else s[: n - 1] + "…"
165
+
166
+
167
+ class Store:
168
+ def __init__(self, path: Path):
169
+ self.path = path
170
+ self.path.parent.mkdir(parents=True, exist_ok=True)
171
+ self._init()
172
+
173
+ def conn(self):
174
+ conn = sqlite3.connect(self.path, timeout=30)
175
+ conn.row_factory = sqlite3.Row
176
+ return conn
177
+
178
+ def _init(self):
179
+ with self.conn() as c:
180
+ c.executescript(
181
+ """
182
+ PRAGMA journal_mode=WAL;
183
+ CREATE TABLE IF NOT EXISTS players (
184
+ id INTEGER PRIMARY KEY,
185
+ guild_id INTEGER NOT NULL,
186
+ label TEXT NOT NULL,
187
+ platform TEXT NOT NULL,
188
+ identifier TEXT NOT NULL,
189
+ handle TEXT,
190
+ tracker_player_id INTEGER,
191
+ alert_channel_id INTEGER,
192
+ enabled INTEGER NOT NULL DEFAULT 1,
193
+ created_at TEXT NOT NULL,
194
+ alert_watermark_at TEXT,
195
+ UNIQUE(guild_id, platform, identifier)
196
+ );
197
+
198
+ CREATE TABLE IF NOT EXISTS snapshots (
199
+ id INTEGER PRIMARY KEY,
200
+ player_id INTEGER NOT NULL,
201
+ observed_at TEXT NOT NULL,
202
+ tracker_updated_at TEXT,
203
+ playlist_json TEXT NOT NULL,
204
+ FOREIGN KEY(player_id) REFERENCES players(id)
205
+ );
206
+ CREATE INDEX IF NOT EXISTS idx_snapshots_player_time
207
+ ON snapshots(player_id, observed_at DESC);
208
+
209
+ CREATE TABLE IF NOT EXISTS matches (
210
+ id INTEGER PRIMARY KEY,
211
+ player_id INTEGER NOT NULL,
212
+ external_id TEXT NOT NULL,
213
+ collected_at TEXT,
214
+ playlist TEXT,
215
+ result TEXT,
216
+ rating INTEGER,
217
+ rating_delta INTEGER,
218
+ tier TEXT,
219
+ division TEXT,
220
+ is_grouped INTEGER,
221
+ matches_played INTEGER,
222
+ wins INTEGER,
223
+ goals INTEGER,
224
+ assists INTEGER,
225
+ saves INTEGER,
226
+ shots INTEGER,
227
+ mvps INTEGER,
228
+ first_seen_at TEXT NOT NULL,
229
+ raw_json TEXT,
230
+ FOREIGN KEY(player_id) REFERENCES players(id),
231
+ UNIQUE(player_id, external_id)
232
+ );
233
+ CREATE INDEX IF NOT EXISTS idx_matches_player_collected
234
+ ON matches(player_id, collected_at DESC);
235
+ """
236
+ )
237
+ cols = {row["name"] for row in c.execute("PRAGMA table_info(players)")}
238
+ if "alert_watermark_at" not in cols:
239
+ c.execute("ALTER TABLE players ADD COLUMN alert_watermark_at TEXT")
240
+
241
+ def add_player(self, guild_id: int, label: str, platform: str, identifier: str, channel_id: int) -> int:
242
+ with self.conn() as c:
243
+ c.execute(
244
+ """
245
+ INSERT INTO players(guild_id,label,platform,identifier,alert_channel_id,created_at)
246
+ VALUES(?,?,?,?,?,?)
247
+ ON CONFLICT(guild_id, platform, identifier) DO UPDATE SET
248
+ label=excluded.label,
249
+ alert_channel_id=excluded.alert_channel_id,
250
+ enabled=1
251
+ """,
252
+ (guild_id, label, platform, identifier, channel_id, utc_now_iso()),
253
+ )
254
+ row = c.execute(
255
+ "SELECT id FROM players WHERE guild_id=? AND platform=? AND identifier=?",
256
+ (guild_id, platform, identifier),
257
+ ).fetchone()
258
+ return int(row["id"])
259
+
260
+ def remove_player(self, guild_id: int, query: str) -> bool:
261
+ with self.conn() as c:
262
+ cur = c.execute(
263
+ "DELETE FROM players WHERE guild_id=? AND (label=? COLLATE NOCASE OR identifier=? COLLATE NOCASE)",
264
+ (guild_id, query, query),
265
+ )
266
+ return cur.rowcount > 0
267
+
268
+ def players(self, guild_id: Optional[int] = None):
269
+ with self.conn() as c:
270
+ if guild_id is None:
271
+ return c.execute("SELECT * FROM players WHERE enabled=1 ORDER BY guild_id,label").fetchall()
272
+ return c.execute(
273
+ "SELECT * FROM players WHERE enabled=1 AND guild_id=? ORDER BY label COLLATE NOCASE",
274
+ (guild_id,),
275
+ ).fetchall()
276
+
277
+ def get_player(self, guild_id: int, query: str):
278
+ with self.conn() as c:
279
+ return c.execute(
280
+ """
281
+ SELECT * FROM players
282
+ WHERE guild_id=? AND enabled=1
283
+ AND (label=? COLLATE NOCASE OR handle=? COLLATE NOCASE OR identifier=? COLLATE NOCASE)
284
+ LIMIT 1
285
+ """,
286
+ (guild_id, query, query, query),
287
+ ).fetchone()
288
+
289
+ def update_identity(self, player_id: int, handle: str, tracker_player_id: Optional[int]):
290
+ with self.conn() as c:
291
+ c.execute(
292
+ "UPDATE players SET handle=?, tracker_player_id=? WHERE id=?",
293
+ (handle, tracker_player_id, player_id),
294
+ )
295
+
296
+ def set_alert_watermark(self, player_id: int, value: Optional[str]) -> None:
297
+ if not value:
298
+ return
299
+ with self.conn() as c:
300
+ c.execute("UPDATE players SET alert_watermark_at=? WHERE id=?", (value, player_id))
301
+
302
+ def ensure_alert_watermark(self, player_id: int) -> Optional[str]:
303
+ with self.conn() as c:
304
+ row = c.execute("SELECT alert_watermark_at, created_at FROM players WHERE id=?", (player_id,)).fetchone()
305
+ if not row:
306
+ return None
307
+ if row["alert_watermark_at"]:
308
+ return row["alert_watermark_at"]
309
+ latest = c.execute(
310
+ "SELECT collected_at FROM matches WHERE player_id=? AND collected_at IS NOT NULL ORDER BY collected_at DESC LIMIT 1",
311
+ (player_id,),
312
+ ).fetchone()
313
+ value = latest["collected_at"] if latest else row["created_at"]
314
+ c.execute("UPDATE players SET alert_watermark_at=? WHERE id=?", (value, player_id))
315
+ return value
316
+
317
+ def add_snapshot(self, player_id: int, profile) -> None:
318
+ with self.conn() as c:
319
+ c.execute(
320
+ "INSERT INTO snapshots(player_id,observed_at,tracker_updated_at,playlist_json) VALUES(?,?,?,?)",
321
+ (
322
+ player_id,
323
+ utc_now_iso(),
324
+ profile.last_updated.isoformat() if profile.last_updated else None,
325
+ json.dumps(profile.playlists, separators=(",", ":")),
326
+ ),
327
+ )
328
+
329
+ def latest_snapshot(self, player_id: int):
330
+ with self.conn() as c:
331
+ return c.execute(
332
+ "SELECT * FROM snapshots WHERE player_id=? ORDER BY observed_at DESC LIMIT 1",
333
+ (player_id,),
334
+ ).fetchone()
335
+
336
+ def insert_matches(self, player_id: int, matches: list[dict[str, Any]]) -> list[dict[str, Any]]:
337
+ newly_added: list[dict[str, Any]] = []
338
+ with self.conn() as c:
339
+ for m in matches:
340
+ ext = m.get("match_id")
341
+ if not ext:
342
+ continue
343
+ cur = c.execute(
344
+ """
345
+ INSERT OR IGNORE INTO matches(
346
+ player_id,external_id,collected_at,playlist,result,rating,rating_delta,tier,division,
347
+ is_grouped,matches_played,wins,goals,assists,saves,shots,mvps,first_seen_at,raw_json
348
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
349
+ """,
350
+ (
351
+ player_id, ext, m.get("collected_at"), m.get("playlist"), m.get("result"),
352
+ m.get("rating"), m.get("rating_delta"), m.get("tier"), m.get("division"),
353
+ int(bool(m.get("is_grouped"))), m.get("matches_played"), m.get("wins"),
354
+ m.get("goals"), m.get("assists"), m.get("saves"), m.get("shots"), m.get("mvps"),
355
+ utc_now_iso(), json.dumps(m, separators=(",", ":")),
356
+ ),
357
+ )
358
+ if cur.rowcount:
359
+ newly_added.append(m)
360
+ return newly_added
361
+
362
+ def recent_matches(self, player_id: int, limit: int = 10):
363
+ with self.conn() as c:
364
+ return c.execute(
365
+ "SELECT * FROM matches WHERE player_id=? ORDER BY collected_at DESC LIMIT ?",
366
+ (player_id, limit),
367
+ ).fetchall()
368
+
369
+ def latest_match(self, player_id: int):
370
+ rows = self.recent_matches(player_id, 1)
371
+ return rows[0] if rows else None
372
+
373
+
374
+ def export_players(self) -> list[dict[str, Any]]:
375
+ with self.conn() as c:
376
+ rows = c.execute("SELECT * FROM players ORDER BY guild_id,label COLLATE NOCASE").fetchall()
377
+ return [dict(r) for r in rows]
378
+
379
+ def export_matches(self, limit: int = 5000) -> list[dict[str, Any]]:
380
+ with self.conn() as c:
381
+ rows = c.execute(
382
+ """
383
+ SELECT p.guild_id,p.platform,p.identifier,p.label,m.*
384
+ FROM matches m JOIN players p ON p.id=m.player_id
385
+ ORDER BY m.collected_at DESC, m.id DESC LIMIT ?
386
+ """,
387
+ (limit,),
388
+ ).fetchall()
389
+ out = []
390
+ for r in rows:
391
+ d = dict(r)
392
+ d.pop("id", None)
393
+ d.pop("player_id", None)
394
+ out.append(d)
395
+ return out
396
+
397
+ def restore_player(self, item: dict[str, Any]) -> Optional[int]:
398
+ try:
399
+ guild_id = int(item["guild_id"])
400
+ platform = str(item["platform"])
401
+ identifier = str(item["identifier"])
402
+ label = str(item.get("label") or identifier)
403
+ channel_id = int(item.get("alert_channel_id") or 0)
404
+ except (KeyError, TypeError, ValueError):
405
+ return None
406
+ with self.conn() as c:
407
+ c.execute(
408
+ """
409
+ INSERT INTO players(
410
+ guild_id,label,platform,identifier,handle,tracker_player_id,
411
+ alert_channel_id,enabled,created_at,alert_watermark_at
412
+ ) VALUES(?,?,?,?,?,?,?,?,?,?)
413
+ ON CONFLICT(guild_id,platform,identifier) DO UPDATE SET
414
+ label=excluded.label,
415
+ handle=COALESCE(excluded.handle,players.handle),
416
+ tracker_player_id=COALESCE(excluded.tracker_player_id,players.tracker_player_id),
417
+ alert_channel_id=CASE WHEN excluded.alert_channel_id>0 THEN excluded.alert_channel_id ELSE players.alert_channel_id END,
418
+ enabled=excluded.enabled,
419
+ alert_watermark_at=COALESCE(excluded.alert_watermark_at,players.alert_watermark_at)
420
+ """,
421
+ (
422
+ guild_id,label,platform,identifier,item.get("handle"),item.get("tracker_player_id"),
423
+ channel_id,int(item.get("enabled",1)),item.get("created_at") or utc_now_iso(),item.get("alert_watermark_at"),
424
+ ),
425
+ )
426
+ row=c.execute(
427
+ "SELECT id FROM players WHERE guild_id=? AND platform=? AND identifier=?",
428
+ (guild_id,platform,identifier),
429
+ ).fetchone()
430
+ return int(row["id"]) if row else None
431
+
432
+ def restore_match(self, player_id: int, item: dict[str, Any]) -> None:
433
+ ext = item.get("external_id") or item.get("match_id")
434
+ if not ext:
435
+ return
436
+ with self.conn() as c:
437
+ c.execute(
438
+ """
439
+ INSERT OR IGNORE INTO matches(
440
+ player_id,external_id,collected_at,playlist,result,rating,rating_delta,tier,division,
441
+ is_grouped,matches_played,wins,goals,assists,saves,shots,mvps,first_seen_at,raw_json
442
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
443
+ """,
444
+ (
445
+ player_id,ext,item.get("collected_at"),item.get("playlist"),item.get("result"),
446
+ item.get("rating"),item.get("rating_delta"),item.get("tier"),item.get("division"),
447
+ int(bool(item.get("is_grouped"))),item.get("matches_played"),item.get("wins"),
448
+ item.get("goals"),item.get("assists"),item.get("saves"),item.get("shots"),item.get("mvps"),
449
+ item.get("first_seen_at") or utc_now_iso(),item.get("raw_json") or json.dumps(item,separators=(",",":")),
450
+ ),
451
+ )
452
+
453
+
454
+ class GitHubDataStore:
455
+ API = "https://api.github.com"
456
+
457
+ def __init__(self, token: str, repo: str, branch: str = "main"):
458
+ self.token = token
459
+ self.repo = repo
460
+ self.branch = branch
461
+ self.enabled = bool(token and repo)
462
+ self.session = requests.Session()
463
+ if self.enabled:
464
+ self.session.headers.update({
465
+ "Authorization": f"Bearer {token}",
466
+ "Accept": "application/vnd.github+json",
467
+ "X-GitHub-Api-Version": "2022-11-28",
468
+ "User-Agent": "rocket-watch-hf/1.0",
469
+ })
470
+ self.last_sync_at: Optional[str] = None
471
+ self.last_error: Optional[str] = None
472
+
473
+ def _url(self, path: str) -> str:
474
+ return f"{self.API}/repos/{self.repo}/contents/{path}"
475
+
476
+ def read_json(self, path: str, default: dict[str, Any]) -> tuple[dict[str, Any], Optional[str]]:
477
+ if not self.enabled:
478
+ return default, None
479
+ r = self.session.get(self._url(path), params={"ref": self.branch}, timeout=20)
480
+ if r.status_code == 404:
481
+ return default, None
482
+ r.raise_for_status()
483
+ payload = r.json()
484
+ raw = base64.b64decode(payload.get("content", "")).decode("utf-8")
485
+ return json.loads(raw), payload.get("sha")
486
+
487
+ def write_json(self, path: str, data: dict[str, Any], message: str) -> None:
488
+ if not self.enabled:
489
+ return
490
+ _, sha = self.read_json(path, {})
491
+ body = {
492
+ "message": message,
493
+ "content": base64.b64encode((json.dumps(data, indent=2, sort_keys=False) + "\n").encode()).decode(),
494
+ "branch": self.branch,
495
+ }
496
+ if sha:
497
+ body["sha"] = sha
498
+ r = self.session.put(self._url(path), json=body, timeout=30)
499
+ r.raise_for_status()
500
+
501
+ def ensure_files(self) -> None:
502
+ if not self.enabled:
503
+ return
504
+ defaults = {
505
+ "datasets/users.json": {"schema_version": 1, "updated_at": None, "players": []},
506
+ "datasets/activity.json": {"schema_version": 1, "updated_at": None, "matches": []},
507
+ }
508
+ for path, default in defaults.items():
509
+ data, sha = self.read_json(path, default)
510
+ if sha is None:
511
+ self.write_json(path, data, f"Create {path}")
512
+
513
+ def restore_into(self, store: Store) -> dict[str, int]:
514
+ if not self.enabled:
515
+ return {"players": 0, "matches": 0}
516
+ users, _ = self.read_json("datasets/users.json", {"players": []})
517
+ activity, _ = self.read_json("datasets/activity.json", {"matches": []})
518
+ key_to_id: dict[tuple[int,str,str], int] = {}
519
+ restored_players = 0
520
+ for item in users.get("players") or []:
521
+ pid = store.restore_player(item)
522
+ if pid is not None:
523
+ key_to_id[(int(item["guild_id"]), str(item["platform"]), str(item["identifier"]))] = pid
524
+ restored_players += 1
525
+ restored_matches = 0
526
+ for item in activity.get("matches") or []:
527
+ try:
528
+ key = (int(item["guild_id"]), str(item["platform"]), str(item["identifier"]))
529
+ except (KeyError, TypeError, ValueError):
530
+ continue
531
+ pid = key_to_id.get(key)
532
+ if pid is None:
533
+ row = store.get_player(key[0], key[2])
534
+ pid = int(row["id"]) if row else None
535
+ if pid is not None:
536
+ store.restore_match(pid, item)
537
+ restored_matches += 1
538
+ self.last_sync_at = utc_now_iso()
539
+ self.last_error = None
540
+ return {"players": restored_players, "matches": restored_matches}
541
+
542
+ def push_all(self, store: Store, reason: str = "Sync RL dev tracker datasets") -> None:
543
+ if not self.enabled:
544
+ return
545
+ now = utc_now_iso()
546
+ users = {"schema_version": 1, "updated_at": now, "players": store.export_players()}
547
+ activity = {"schema_version": 1, "updated_at": now, "matches": store.export_matches(GITHUB_SYNC_ACTIVITY_LIMIT)}
548
+ self.write_json("datasets/users.json", users, f"{reason}: users")
549
+ self.write_json("datasets/activity.json", activity, f"{reason}: activity")
550
+ self.last_sync_at = now
551
+ self.last_error = None
552
+
553
+
554
+ github_data = GitHubDataStore(GHT, GITHUB_DATA_REPO, GITHUB_DATA_BRANCH)
555
+
556
+
557
+ store = Store(DB_PATH)
558
+ tracker = TrackerClient()
559
+
560
+
561
+ async def github_push(reason: str = "Sync RL dev tracker datasets") -> bool:
562
+ if not github_data.enabled:
563
+ return False
564
+ try:
565
+ await asyncio.to_thread(github_data.push_all, store, reason)
566
+ return True
567
+ except Exception as exc:
568
+ github_data.last_error = f"{type(exc).__name__}: {exc}"
569
+ log.warning("GitHub dataset sync failed: %s", exc)
570
+ return False
571
+
572
+
573
+ intents = discord.Intents.default()
574
+ client = discord.Client(intents=intents)
575
+ tree = app_commands.CommandTree(client)
576
+
577
+ # Runtime state used by the health endpoint.
578
+ runtime = {
579
+ "discord_connected": False,
580
+ "discord_user": None,
581
+ "discord_last_error": None,
582
+ "discord_last_attempt": None,
583
+ "discord_last_connected": None,
584
+ "discord_retry_seconds": None,
585
+ "discord_stage": "idle",
586
+ "discord_stage_since": None,
587
+ }
588
+
589
+
590
+ async def fetch_and_store(player_row, *, seed: bool = False) -> tuple[Any, list[dict[str, Any]]]:
591
+ profile = await asyncio.to_thread(tracker.profile, player_row["platform"], player_row["identifier"])
592
+ sessions_payload = await asyncio.to_thread(tracker.sessions, player_row["platform"], player_row["identifier"])
593
+ matches = tracker.flatten_session_matches(sessions_payload)
594
+
595
+ store.update_identity(player_row["id"], profile.handle, profile.player_id)
596
+ store.add_snapshot(player_row["id"], profile)
597
+ new_matches = store.insert_matches(player_row["id"], matches)
598
+
599
+ # On initial add, establish a watermark at the newest historical match. Tracker can
600
+ # reveal older/grouped rows later, and those must never be announced as live activity.
601
+ if seed:
602
+ dated = [m for m in matches if parse_dt(m.get("collected_at"))]
603
+ if dated:
604
+ newest = max(dated, key=lambda m: parse_dt(m.get("collected_at")))
605
+ store.set_alert_watermark(player_row["id"], newest.get("collected_at"))
606
+ else:
607
+ store.set_alert_watermark(player_row["id"], utc_now_iso())
608
+ return profile, []
609
+
610
+ return profile, new_matches
611
+
612
+
613
+ def profile_embed(player_row, profile, latest_match=None) -> discord.Embed:
614
+ e = discord.Embed(
615
+ title=f"{profile.handle} — Rocket League",
616
+ description=f"{profile.platform.upper()} • Season {profile.season or '?'}",
617
+ color=discord.Color.blurple(),
618
+ timestamp=datetime.now(timezone.utc),
619
+ )
620
+ if profile.avatar_url:
621
+ e.set_thumbnail(url=profile.avatar_url)
622
+
623
+ if latest_match:
624
+ e.add_field(
625
+ name="Activity",
626
+ value=f"**{activity_label(latest_match['collected_at'])}** • {discord_time(latest_match['collected_at'], 'R')}\n{discord_time(latest_match['collected_at'], 'f')}",
627
+ inline=True,
628
+ )
629
+ e.add_field(name="Last mode", value=latest_match["playlist"] or "Unknown", inline=True)
630
+ if latest_match["rating"] is not None:
631
+ delta = latest_match["rating_delta"]
632
+ ds = f" ({delta:+d})" if delta is not None else ""
633
+ e.add_field(name="Last MMR", value=f"{latest_match['rating']}{ds}", inline=True)
634
+
635
+ ranked = [p for p in profile.playlists if p.get("rating") is not None]
636
+ ranked.sort(key=lambda p: ("Ranked" not in (p.get("name") or ""), p.get("name") or ""))
637
+ for p in ranked[:10]:
638
+ rank = " ".join(x for x in [p.get("tier"), p.get("division")] if x) or "—"
639
+ peak = f" • peak {p['peak_rating']}" if p.get("peak_rating") is not None else ""
640
+ e.add_field(
641
+ name=trunc(p.get("name") or "Playlist", 256),
642
+ value=f"**{p['rating']} MMR** • {rank}{peak}",
643
+ inline=False,
644
+ )
645
+ e.set_footer(text="Tracker collection times can lag actual in-game activity.")
646
+ return e
647
+
648
+
649
+ def match_embed(player_row, match: dict[str, Any]) -> discord.Embed:
650
+ rating = match.get("rating")
651
+ delta = match.get("rating_delta")
652
+ mmr = "Unknown"
653
+ if rating is not None:
654
+ mmr = str(rating)
655
+ if delta is not None:
656
+ mmr += f" ({delta:+d})"
657
+ result = match.get("result") or "activity"
658
+ e = discord.Embed(
659
+ title=f"New RL activity — {player_row['label']}",
660
+ description=f"**{match.get('playlist') or 'Unknown playlist'}** • {result}",
661
+ color=discord.Color.green() if str(result).lower() == "victory" else discord.Color.orange(),
662
+ timestamp=parse_dt(match.get("collected_at")) or datetime.now(timezone.utc),
663
+ )
664
+ e.add_field(name="MMR", value=mmr, inline=True)
665
+ rank = " ".join(x for x in [match.get("tier"), match.get("division")] if x)
666
+ if rank:
667
+ e.add_field(name="Rank", value=rank, inline=True)
668
+ e.add_field(
669
+ name="Observed",
670
+ value=f"{discord_time(match.get('collected_at'), 'R')}\n{discord_time(match.get('collected_at'), 'f')}",
671
+ inline=True,
672
+ )
673
+ e.add_field(name="Matchmaking signal", value=queue_window_text(match.get("collected_at")), inline=False)
674
+ stats = []
675
+ for k, label in [("goals", "G"), ("assists", "A"), ("saves", "S"), ("shots", "Shots")]:
676
+ if match.get(k) is not None:
677
+ stats.append(f"{label}: {match[k]}")
678
+ if stats:
679
+ e.add_field(name="Stats", value=" • ".join(stats), inline=False)
680
+ e.set_footer(text="Activity is inferred from Tracker updates; it is not a direct online-status signal.")
681
+ return e
682
+
683
+
684
+ @client.event
685
+ async def on_ready():
686
+ runtime["discord_connected"] = True
687
+ runtime["discord_user"] = str(client.user) if client.user else None
688
+ runtime["discord_last_connected"] = utc_now_iso()
689
+ runtime["discord_last_error"] = None
690
+ runtime["discord_retry_seconds"] = None
691
+ log.info("Logged in as %s (%s)", client.user, client.user.id if client.user else "?")
692
+ if not watcher.is_running():
693
+ watcher.change_interval(seconds=POLL_SECONDS)
694
+ watcher.start()
695
+
696
+
697
+ @client.event
698
+ async def on_disconnect():
699
+ runtime["discord_connected"] = False
700
+
701
+
702
+ @tree.command(name="track_add", description="Track a Rocket League account and alert this channel on new activity")
703
+ @app_commands.describe(platform="steam, epic, xbox, or psn", identifier="Tracker-compatible account identifier", label="Short name shown by the bot")
704
+ async def track_add(interaction: discord.Interaction, platform: str, identifier: str, label: Optional[str] = None):
705
+ if not interaction.guild_id or not interaction.channel_id:
706
+ await interaction.response.send_message("Use this command inside a server channel.", ephemeral=True)
707
+ return
708
+ platform = platform.strip().lower()
709
+ aliases = {"xbox": "xbl", "playstation": "psn", "epicgames": "epic"}
710
+ platform = aliases.get(platform, platform)
711
+ if platform not in {"steam", "epic", "xbl", "psn"}:
712
+ await interaction.response.send_message("Platform must be `steam`, `epic`, `xbl`, or `psn`.", ephemeral=True)
713
+ return
714
+
715
+ await interaction.response.defer(ephemeral=True)
716
+ row_id = store.add_player(interaction.guild_id, label or identifier, platform, identifier.strip(), interaction.channel_id)
717
+ row = store.get_player(interaction.guild_id, label or identifier)
718
+ if not row:
719
+ # If label changed on an existing record, fetch by identifier.
720
+ row = store.get_player(interaction.guild_id, identifier.strip())
721
+ try:
722
+ profile, _ = await fetch_and_store(row, seed=True)
723
+ except TrackerError as exc:
724
+ store.remove_player(interaction.guild_id, identifier.strip())
725
+ await interaction.followup.send(f"Could not load that Tracker profile: `{exc}`", ephemeral=True)
726
+ return
727
+
728
+ await github_push("Track add")
729
+ await interaction.followup.send(
730
+ f"Tracking **{profile.handle}** as **{label or profile.handle}**. New activity will be posted in <#{interaction.channel_id}>. Historical sessions were seeded without alerts.",
731
+ ephemeral=True,
732
+ )
733
+
734
+
735
+ @tree.command(name="track_remove", description="Stop tracking a Rocket League account")
736
+ async def track_remove(interaction: discord.Interaction, player: str):
737
+ if not interaction.guild_id:
738
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
739
+ return
740
+ ok = store.remove_player(interaction.guild_id, player.strip())
741
+ if ok:
742
+ await github_push("Track remove")
743
+ await interaction.response.send_message("Removed." if ok else "No matching tracked player.", ephemeral=True)
744
+
745
+
746
+ @tree.command(name="track_list", description="List Rocket League accounts tracked in this server")
747
+ async def track_list(interaction: discord.Interaction):
748
+ if not interaction.guild_id:
749
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
750
+ return
751
+ rows = store.players(interaction.guild_id)
752
+ if not rows:
753
+ await interaction.response.send_message("No players are being tracked yet.", ephemeral=True)
754
+ return
755
+ lines = []
756
+ for r in rows:
757
+ last = store.latest_match(r["id"])
758
+ status = activity_label(last["collected_at"]) if last else "UNKNOWN"
759
+ mode = last["playlist"] if last else "no session data"
760
+ lines.append(f"• **{r['label']}** — `{r['platform']}` / `{r['identifier']}` — **{status}** — {mode}")
761
+ await interaction.response.send_message("\n".join(lines), ephemeral=True)
762
+
763
+
764
+ @tree.command(name="rl_player", description="Show current MMRs and recent activity for a tracked player")
765
+ async def rl_player(interaction: discord.Interaction, player: str):
766
+ if not interaction.guild_id:
767
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
768
+ return
769
+ row = store.get_player(interaction.guild_id, player.strip())
770
+ if not row:
771
+ await interaction.response.send_message("Player not found. Use `/track_list`.", ephemeral=True)
772
+ return
773
+ await interaction.response.defer()
774
+ try:
775
+ profile, _ = await fetch_and_store(row, seed=False)
776
+ except TrackerError as exc:
777
+ await interaction.followup.send(f"Tracker request failed: `{exc}`")
778
+ return
779
+ latest = store.latest_match(row["id"])
780
+ await interaction.followup.send(embed=profile_embed(row, profile, latest))
781
+
782
+
783
+ @tree.command(name="rl_recent", description="Show a tracked player's recent recorded matches/activity")
784
+ @app_commands.describe(count="Number of entries to show (1-10)")
785
+ async def rl_recent(interaction: discord.Interaction, player: str, count: app_commands.Range[int, 1, 10] = 5):
786
+ if not interaction.guild_id:
787
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
788
+ return
789
+ row = store.get_player(interaction.guild_id, player.strip())
790
+ if not row:
791
+ await interaction.response.send_message("Player not found. Use `/track_list`.", ephemeral=True)
792
+ return
793
+ matches = store.recent_matches(row["id"], count)
794
+ if not matches:
795
+ await interaction.response.send_message("No session records stored yet.")
796
+ return
797
+ lines = []
798
+ for m in matches:
799
+ mmr = "?" if m["rating"] is None else str(m["rating"])
800
+ if m["rating_delta"] is not None:
801
+ mmr += f" ({m['rating_delta']:+d})"
802
+ lines.append(
803
+ f"**{m['playlist'] or 'Unknown'}** — {m['result'] or 'activity'} — `{mmr} MMR` — "
804
+ f"{discord_time(m['collected_at'], 'R')} ({discord_time(m['collected_at'], 't')})"
805
+ )
806
+ e = discord.Embed(title=f"Recent activity — {row['label']}", description="\n".join(lines), color=discord.Color.blurple())
807
+ await interaction.response.send_message(embed=e)
808
+
809
+
810
+ @tree.command(name="rl_refresh", description="Refresh all tracked players in this server now")
811
+ async def rl_refresh(interaction: discord.Interaction):
812
+ if not interaction.guild_id:
813
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
814
+ return
815
+ await interaction.response.defer(ephemeral=True)
816
+ rows = store.players(interaction.guild_id)
817
+ ok = 0
818
+ errors = []
819
+ for row in rows:
820
+ try:
821
+ await fetch_and_store(row, seed=False)
822
+ ok += 1
823
+ except TrackerError as exc:
824
+ errors.append(f"{row['label']}: {exc}")
825
+ if ok:
826
+ await github_push("Manual refresh")
827
+ msg = f"Refreshed {ok}/{len(rows)} tracked players."
828
+ if errors:
829
+ msg += "\n" + "\n".join(f"• {trunc(e, 160)}" for e in errors[:5])
830
+ await interaction.followup.send(msg, ephemeral=True)
831
+
832
+
833
+ @tree.command(name="watch_status", description="Show the most useful current matchmaking signal for a tracked player")
834
+ async def watch_status(interaction: discord.Interaction, player: str):
835
+ if not interaction.guild_id:
836
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
837
+ return
838
+ row = store.get_player(interaction.guild_id, player.strip())
839
+ if not row:
840
+ await interaction.response.send_message("Player not found. Use `/track_list`.", ephemeral=True)
841
+ return
842
+ latest = store.latest_match(row["id"])
843
+ if not latest:
844
+ await interaction.response.send_message("No activity has been recorded for that player yet.")
845
+ return
846
+ rating = "?" if latest["rating"] is None else str(latest["rating"])
847
+ if latest["rating_delta"] is not None:
848
+ rating += f" ({latest['rating_delta']:+d})"
849
+ e = discord.Embed(title=f"Watch status — {row['label']}", color=discord.Color.blurple())
850
+ e.add_field(name="Playlist", value=latest["playlist"] or "Unknown", inline=True)
851
+ e.add_field(name="MMR", value=rating, inline=True)
852
+ e.add_field(name="Freshness", value=activity_label(latest["collected_at"]), inline=True)
853
+ e.add_field(name="Observed", value=f"{discord_time(latest['collected_at'],'R')}\n{discord_time(latest['collected_at'],'f')}", inline=False)
854
+ e.add_field(name="Matchmaking signal", value=queue_window_text(latest["collected_at"]), inline=False)
855
+ e.set_footer(text="Tracker collection times can lag and do not prove the player is currently queued.")
856
+ await interaction.response.send_message(embed=e)
857
+
858
+
859
+ @tree.command(name="watch_hot", description="List tracked players with the freshest recent activity")
860
+ async def watch_hot(interaction: discord.Interaction):
861
+ if not interaction.guild_id:
862
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
863
+ return
864
+ entries=[]
865
+ for row in store.players(interaction.guild_id):
866
+ last=store.latest_match(row["id"])
867
+ if not last:
868
+ continue
869
+ dt=parse_dt(last["collected_at"])
870
+ if not dt:
871
+ continue
872
+ entries.append((dt,row,last))
873
+ if not entries:
874
+ await interaction.response.send_message("No tracked activity is stored yet.", ephemeral=True)
875
+ return
876
+ entries.sort(key=lambda x:x[0], reverse=True)
877
+ lines=[]
878
+ for _,row,last in entries[:15]:
879
+ mmr="?" if last["rating"] is None else str(last["rating"])
880
+ lines.append(f"**{row['label']}** — {last['playlist'] or 'Unknown'} — `{mmr} MMR` — **{activity_label(last['collected_at'])}** — {discord_time(last['collected_at'],'R')}")
881
+ await interaction.response.send_message(embed=discord.Embed(title="Freshest tracked activity",description="\n".join(lines),color=discord.Color.blurple()))
882
+
883
+
884
+ @tree.command(name="github_sync", description="Manually sync the bot datasets to GitHub")
885
+ async def github_sync_cmd(interaction: discord.Interaction):
886
+ await interaction.response.defer(ephemeral=True)
887
+ if not github_data.enabled:
888
+ await interaction.followup.send("GitHub persistence is disabled. Add `GHT` as a Hugging Face secret.", ephemeral=True)
889
+ return
890
+ ok=await github_push("Manual Discord sync")
891
+ if ok:
892
+ await interaction.followup.send(f"Synced datasets to `{GITHUB_DATA_REPO}`.", ephemeral=True)
893
+ else:
894
+ await interaction.followup.send(f"GitHub sync failed: `{trunc(github_data.last_error or 'unknown error',300)}`", ephemeral=True)
895
+
896
+
897
+ @tree.command(name="github_status", description="Show GitHub persistence status")
898
+ async def github_status_cmd(interaction: discord.Interaction):
899
+ state="enabled" if github_data.enabled else "disabled"
900
+ msg=(f"GitHub persistence: **{state}**\nRepo: `{GITHUB_DATA_REPO}`\nBranch: `{GITHUB_DATA_BRANCH}`\n"
901
+ f"Last sync: {discord_time(github_data.last_sync_at,'R') if github_data.last_sync_at else 'never'}")
902
+ if github_data.last_error:
903
+ msg += f"\nLast error: `{trunc(github_data.last_error,300)}`"
904
+ await interaction.response.send_message(msg, ephemeral=True)
905
+
906
+
907
+ @tasks.loop(seconds=120)
908
+ async def watcher():
909
+ rows = store.players()
910
+ for row in rows:
911
+ try:
912
+ profile, new_matches = await fetch_and_store(row, seed=False)
913
+ if not new_matches:
914
+ continue
915
+
916
+ watermark_raw = store.ensure_alert_watermark(row["id"])
917
+ watermark = parse_dt(watermark_raw)
918
+ now = datetime.now(timezone.utc)
919
+ filtered: list[dict[str, Any]] = []
920
+ for match in new_matches:
921
+ collected = parse_dt(match.get("collected_at"))
922
+ if not collected:
923
+ continue
924
+ # Grouped/aggregate Tracker rows such as "Multiple" can be backfilled later and
925
+ # are not reliable enough for live matchmaking alerts. Keep them in history only.
926
+ if match.get("is_grouped"):
927
+ continue
928
+ if watermark and collected <= watermark:
929
+ continue
930
+ if (now - collected).total_seconds() > ALERT_MAX_AGE_MINUTES * 60:
931
+ continue
932
+ filtered.append(match)
933
+
934
+ if not filtered:
935
+ continue
936
+
937
+ filtered.sort(key=lambda m: m.get("collected_at") or "", reverse=True)
938
+ channel = client.get_channel(row["alert_channel_id"])
939
+ if channel is None:
940
+ try:
941
+ channel = await client.fetch_channel(row["alert_channel_id"])
942
+ except discord.DiscordException:
943
+ log.warning("Could not resolve channel %s", row["alert_channel_id"])
944
+ continue
945
+ sent_any = False
946
+ for match in filtered[:3]:
947
+ try:
948
+ await channel.send(embed=match_embed(row, match))
949
+ store.set_alert_watermark(row["id"], match.get("collected_at"))
950
+ sent_any = True
951
+ except discord.DiscordException as exc:
952
+ log.warning("Failed sending alert for %s: %s", row["label"], exc)
953
+ if sent_any:
954
+ await github_push("New Tracker activity")
955
+ except TrackerError as exc:
956
+ log.warning("Tracker error for %s: %s", row["label"], exc)
957
+ except Exception:
958
+ log.exception("Unexpected watcher error for %s", row["label"])
959
+ await asyncio.sleep(1)
960
+
961
+
962
+ @watcher.before_loop
963
+ async def before_watcher():
964
+ await client.wait_until_ready()
965
+
966
+
967
+ # Hugging Face Docker Spaces expect an HTTP service. This tiny endpoint also gives
968
+ # you a convenient uptime/health check while the Discord bot runs in the same process.
969
+ health = FastAPI(title="Rocket Watch Health")
970
+
971
+
972
+ @health.get("/")
973
+ def root():
974
+ return {
975
+ "ok": True,
976
+ "service": "rocket-watch-discord-bot",
977
+ "discord_connected": runtime["discord_connected"],
978
+ "discord_user": runtime["discord_user"],
979
+ "discord_last_error": runtime["discord_last_error"],
980
+ "discord_last_attempt": runtime["discord_last_attempt"],
981
+ "discord_last_connected": runtime["discord_last_connected"],
982
+ "discord_retry_seconds": runtime["discord_retry_seconds"],
983
+ "discord_stage": runtime["discord_stage"],
984
+ "discord_stage_since": runtime["discord_stage_since"],
985
+ "discord_relay_enabled": bool(DISCORD_RELAY_GATEWAY),
986
+ "discord_relay_origin": DISCORD_RELAY_ORIGIN or None,
987
+ "tracked_players": len(store.players()),
988
+ "poll_seconds": POLL_SECONDS,
989
+ "alert_max_age_minutes": ALERT_MAX_AGE_MINUTES,
990
+ "github_persistence_enabled": github_data.enabled,
991
+ "github_data_repo": GITHUB_DATA_REPO,
992
+ "github_last_sync_at": github_data.last_sync_at,
993
+ "github_last_error": github_data.last_error,
994
+ }
995
+
996
+
997
+ @health.get("/health")
998
+ def healthcheck():
999
+ return {
1000
+ "ok": True,
1001
+ "http": "online",
1002
+ "discord": "connected" if runtime["discord_connected"] else "disconnected",
1003
+ "discord_last_error": runtime["discord_last_error"],
1004
+ "discord_stage": runtime["discord_stage"],
1005
+ "discord_stage_since": runtime["discord_stage_since"],
1006
+ }
1007
+
1008
+
1009
+ def set_discord_stage(stage: str) -> None:
1010
+ runtime["discord_stage"] = stage
1011
+ runtime["discord_stage_since"] = utc_now_iso()
1012
+
1013
+
1014
+ @health.get("/debug/discord")
1015
+ async def debug_discord():
1016
+ """Test both direct Discord and the configured Deno relay without a token."""
1017
+
1018
+ async def probe(label: str, url: str):
1019
+ started = time.monotonic()
1020
+ try:
1021
+ timeout = aiohttp.ClientTimeout(total=10, connect=5, sock_connect=5, sock_read=5)
1022
+ async with aiohttp.ClientSession(timeout=timeout) as session:
1023
+ async with session.get(url) as resp:
1024
+ body = await resp.text()
1025
+ return {
1026
+ "label": label,
1027
+ "url": url.replace(DISCORD_RELAY_SECRET, "***") if DISCORD_RELAY_SECRET else url,
1028
+ "status": resp.status,
1029
+ "body": body[:300],
1030
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
1031
+ }
1032
+ except Exception as exc:
1033
+ return {
1034
+ "label": label,
1035
+ "url": url.replace(DISCORD_RELAY_SECRET, "***") if DISCORD_RELAY_SECRET else url,
1036
+ "error": f"{type(exc).__name__}: {exc}",
1037
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
1038
+ }
1039
+
1040
+ dns = None
1041
+ try:
1042
+ infos = await asyncio.wait_for(
1043
+ asyncio.get_running_loop().run_in_executor(
1044
+ None, lambda: socket.getaddrinfo("discord.com", 443, type=socket.SOCK_STREAM)
1045
+ ),
1046
+ timeout=5,
1047
+ )
1048
+ dns = sorted({item[4][0] for item in infos})[:6]
1049
+ except Exception as exc:
1050
+ dns = f"{type(exc).__name__}: {exc}"
1051
+
1052
+ result = {
1053
+ "discord_dns": dns,
1054
+ "direct": await probe("direct", "https://discord.com/api/v10/gateway"),
1055
+ "relay_enabled": bool(DISCORD_RELAY_GATEWAY),
1056
+ }
1057
+ if DISCORD_RELAY_GATEWAY:
1058
+ relay_url = f"{DISCORD_RELAY_ORIGIN}/p/{DISCORD_RELAY_SECRET}/api/v10/gateway"
1059
+ result["relay"] = await probe("deno-relay", relay_url)
1060
+ return result
1061
+
1062
+
1063
+ async def sync_commands():
1064
+ """Sync slash commands after Discord is actually connected."""
1065
+ try:
1066
+ if GUILD_ID:
1067
+ guild = discord.Object(id=int(GUILD_ID))
1068
+ tree.copy_global_to(guild=guild)
1069
+ synced = await tree.sync(guild=guild)
1070
+ log.info("Synced %d slash commands to guild %s", len(synced), GUILD_ID)
1071
+ else:
1072
+ synced = await tree.sync()
1073
+ log.info("Synced %d global slash commands", len(synced))
1074
+ except Exception:
1075
+ log.exception("Slash-command sync failed")
1076
+
1077
+
1078
+ def rebuild_discord_client() -> None:
1079
+ """Replace the Discord client with a brand-new HTTP/WebSocket stack.
1080
+
1081
+ discord.py can close its internal aiohttp session after a failed login.
1082
+ Client.clear() resets cache/state but does not make that closed session usable
1083
+ again, so retries must use a fresh Client instance.
1084
+ """
1085
+ global client, tree
1086
+
1087
+ # Preserve the application-command objects already registered on the current
1088
+ # tree, then attach them to a fresh tree/client.
1089
+ commands = list(tree.get_commands())
1090
+
1091
+ new_client = discord.Client(intents=intents)
1092
+ new_tree = app_commands.CommandTree(new_client)
1093
+ for command in commands:
1094
+ new_tree.add_command(command)
1095
+
1096
+ # Re-register event callbacks on the new Client. The callbacks intentionally
1097
+ # reference the module-level `client`, which is rebound below.
1098
+ new_client.event(on_ready)
1099
+ new_client.event(on_disconnect)
1100
+
1101
+ client = new_client
1102
+ tree = new_tree
1103
+
1104
+
1105
+ async def discord_supervisor():
1106
+ """Keep Discord failures isolated from the Hugging Face HTTP service.
1107
+
1108
+ The Space listens only on $PORT (7860 by default). Discord uses outbound
1109
+ HTTPS/WSS and therefore does not need another local listening port. Every
1110
+ retry uses a completely fresh discord.Client so a failed aiohttp session
1111
+ cannot poison future attempts.
1112
+ """
1113
+ delay = 5
1114
+ max_delay = 300
1115
+ first_attempt = True
1116
+
1117
+ while True:
1118
+ runtime["discord_last_attempt"] = utc_now_iso()
1119
+ runtime["discord_retry_seconds"] = None
1120
+
1121
+ if not first_attempt:
1122
+ rebuild_discord_client()
1123
+ first_attempt = False
1124
+
1125
+ connect_task = None
1126
+ try:
1127
+ log.info("Connecting to Discord with a fresh client...")
1128
+
1129
+ set_discord_stage("rest_login")
1130
+ await asyncio.wait_for(
1131
+ client.login(DISCORD_BOT_TOKEN),
1132
+ timeout=DISCORD_LOGIN_TIMEOUT,
1133
+ )
1134
+ log.info("Discord REST login succeeded as %s", client.user)
1135
+
1136
+ set_discord_stage("command_sync")
1137
+ await asyncio.wait_for(sync_commands(), timeout=20)
1138
+
1139
+ # client.connect() is intentionally long-running. Start it separately,
1140
+ # then require READY within a bounded amount of time. This distinguishes
1141
+ # a successful REST login from a Gateway/WebSocket failure.
1142
+ set_discord_stage("gateway_connect")
1143
+ connect_task = asyncio.create_task(client.connect(reconnect=True), name="discord-gateway")
1144
+ await asyncio.wait_for(client.wait_until_ready(), timeout=DISCORD_READY_TIMEOUT)
1145
+ set_discord_stage("connected")
1146
+ log.info("Discord gateway is READY")
1147
+
1148
+ # Stay attached to the gateway until discord.py returns or fails.
1149
+ await connect_task
1150
+
1151
+ runtime["discord_connected"] = False
1152
+ runtime["discord_last_error"] = "Discord connection closed"
1153
+ set_discord_stage("disconnected")
1154
+ delay = 5
1155
+ except asyncio.CancelledError:
1156
+ try:
1157
+ if not client.is_closed():
1158
+ await client.close()
1159
+ finally:
1160
+ raise
1161
+ except asyncio.TimeoutError:
1162
+ stage = runtime.get("discord_stage") or "unknown"
1163
+ timeout_used = DISCORD_LOGIN_TIMEOUT if stage == "rest_login" else DISCORD_READY_TIMEOUT
1164
+ runtime["discord_connected"] = False
1165
+ runtime["discord_last_error"] = f"Timeout while in {stage} after {timeout_used}s"
1166
+ log.error("Discord timed out during %s after %ss", stage, timeout_used)
1167
+ except discord.LoginFailure as exc:
1168
+ runtime["discord_connected"] = False
1169
+ runtime["discord_last_error"] = f"Login failure: {exc}"
1170
+ delay = 300
1171
+ log.error("Discord rejected the bot token: %s", exc)
1172
+ except Exception as exc:
1173
+ runtime["discord_connected"] = False
1174
+ runtime["discord_last_error"] = f"{type(exc).__name__}: {exc}"
1175
+ log.exception("Discord connection attempt failed")
1176
+
1177
+ # Best-effort cleanup. We never reuse this instance after a failure.
1178
+ if connect_task is not None and not connect_task.done():
1179
+ connect_task.cancel()
1180
+ try:
1181
+ await connect_task
1182
+ except (asyncio.CancelledError, Exception):
1183
+ pass
1184
+ try:
1185
+ if not client.is_closed():
1186
+ await client.close()
1187
+ except Exception:
1188
+ log.debug("Discord client cleanup failed", exc_info=True)
1189
+
1190
+ runtime["discord_retry_seconds"] = delay
1191
+ set_discord_stage("retry_wait")
1192
+ log.warning("Discord unavailable; retrying with a fresh client in %ss", delay)
1193
+ await asyncio.sleep(delay)
1194
+ delay = min(delay * 2, max_delay)
1195
+
1196
+
1197
+ async def main():
1198
+ if not DISCORD_BOT_TOKEN:
1199
+ raise RuntimeError(
1200
+ "DISCORD_BOT_TOKEN is not set. Add it as a Hugging Face Space secret or environment variable."
1201
+ )
1202
+
1203
+ if github_data.enabled:
1204
+ try:
1205
+ await asyncio.to_thread(github_data.ensure_files)
1206
+ restored = await asyncio.to_thread(github_data.restore_into, store)
1207
+ log.info("Restored %s players and %s activity records from GitHub", restored["players"], restored["matches"])
1208
+ await github_push("Startup reconciliation")
1209
+ except Exception as exc:
1210
+ github_data.last_error = f"{type(exc).__name__}: {exc}"
1211
+ log.warning("GitHub startup restore failed: %s", exc)
1212
+
1213
+ # Hugging Face exposes one HTTP port. Uvicorn owns that port; Discord runs as
1214
+ # an outbound background connection and never binds a local port.
1215
+ port = int(os.getenv("PORT", "7860"))
1216
+ config = uvicorn.Config(
1217
+ health,
1218
+ host="0.0.0.0",
1219
+ port=port,
1220
+ log_level=os.getenv("UVICORN_LOG_LEVEL", "info").lower(),
1221
+ lifespan="on",
1222
+ )
1223
+ server = uvicorn.Server(config)
1224
+
1225
+ discord_task = asyncio.create_task(discord_supervisor(), name="discord-supervisor")
1226
+ try:
1227
+ await server.serve()
1228
+ finally:
1229
+ discord_task.cancel()
1230
+ try:
1231
+ await discord_task
1232
+ except asyncio.CancelledError:
1233
+ pass
1234
+ if not client.is_closed():
1235
+ await client.close()
1236
+
1237
+
1238
+ if __name__ == "__main__":
1239
+ asyncio.run(main())
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ discord.py>=2.5,<3
2
+ requests>=2.32,<3
3
+ fastapi>=0.115,<1
4
+ uvicorn[standard]>=0.34,<1
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