wuhp commited on
Commit
e02abb9
·
verified ·
1 Parent(s): 7a93ecb

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 +847 -0
  5. requirements.txt +4 -0
  6. tracker_client.py +185 -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,847 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any, Optional
13
+
14
+ import discord
15
+ from discord import app_commands
16
+ from discord.ext import tasks
17
+ from fastapi import FastAPI
18
+ import aiohttp
19
+ import uvicorn
20
+ import yarl
21
+
22
+ from tracker_client import TrackerClient, TrackerError
23
+
24
+ APP_DIR = Path(__file__).resolve().parent
25
+ DB_PATH = Path(os.getenv("DB_PATH", str(APP_DIR / "rocket_watch.db")))
26
+ POLL_SECONDS = max(60, int(os.getenv("POLL_SECONDS", "120")))
27
+ DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN", "").strip()
28
+ GUILD_ID = os.getenv("DISCORD_GUILD_ID", "").strip()
29
+ DISCORD_LOGIN_TIMEOUT = max(5, int(os.getenv("DISCORD_LOGIN_TIMEOUT", "20")))
30
+ DISCORD_READY_TIMEOUT = max(10, int(os.getenv("DISCORD_READY_TIMEOUT", "30")))
31
+ DISCORD_RELAY_ORIGIN = (os.getenv("DISCORD_RELAY_ORIGIN") or os.getenv("DISCORD_PROXY_ORIGIN", "")).strip().rstrip("/")
32
+ DISCORD_RELAY_SECRET = (os.getenv("DISCORD_RELAY_SECRET") or os.getenv("DISCORD_PROXY_SECRET", "")).strip().strip("/")
33
+
34
+ logging.basicConfig(
35
+ level=os.getenv("LOG_LEVEL", "INFO").upper(),
36
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
37
+ )
38
+ log = logging.getLogger("rocket-watch")
39
+
40
+
41
+ def configure_discord_relay() -> Optional[str]:
42
+ """Route discord.py REST + Gateway traffic through the external Deno relay.
43
+
44
+ The relay uses a secret path segment so it cannot be used as an open Discord
45
+ proxy by anyone who does not know the shared secret. The bot token is not
46
+ stored in the relay; discord.py sends it as the normal Authorization header.
47
+ """
48
+ if not DISCORD_RELAY_ORIGIN and not DISCORD_RELAY_SECRET:
49
+ return None
50
+ if not DISCORD_RELAY_ORIGIN or not DISCORD_RELAY_SECRET:
51
+ raise RuntimeError(
52
+ "Set both DISCORD_RELAY_ORIGIN and DISCORD_RELAY_SECRET, or neither."
53
+ )
54
+ if not DISCORD_RELAY_ORIGIN.startswith("https://"):
55
+ raise RuntimeError("DISCORD_RELAY_ORIGIN must start with https://")
56
+
57
+ from discord.http import Route
58
+ from discord.gateway import DiscordWebSocket
59
+
60
+ rest_base = f"{DISCORD_RELAY_ORIGIN}/p/{DISCORD_RELAY_SECRET}/api/v10"
61
+ gateway = DISCORD_RELAY_ORIGIN.replace("https://", "wss://", 1)
62
+ gateway = f"{gateway}/p/{DISCORD_RELAY_SECRET}/gateway"
63
+
64
+ Route.BASE = rest_base
65
+ DiscordWebSocket.DEFAULT_GATEWAY = yarl.URL(gateway)
66
+
67
+ # Discord's READY event contains a direct resume_gateway_url. discord.py stores
68
+ # that URL for later RESUME attempts. Restore our proxy URL immediately after
69
+ # READY/RESUMED processing so reconnects never escape the relay.
70
+ original_received_message = DiscordWebSocket.received_message
71
+
72
+ async def proxied_received_message(self, msg):
73
+ result = await original_received_message(self, msg)
74
+ if getattr(self, "session_id", None) and str(getattr(self, "gateway", "")) != gateway:
75
+ self.gateway = yarl.URL(gateway)
76
+ return result
77
+
78
+ if not getattr(DiscordWebSocket.received_message, "_rocket_watch_proxy_patch", False):
79
+ proxied_received_message._rocket_watch_proxy_patch = True
80
+ DiscordWebSocket.received_message = proxied_received_message
81
+
82
+ log.info("Discord relay enabled through %s", DISCORD_RELAY_ORIGIN)
83
+ return gateway
84
+
85
+
86
+ DISCORD_RELAY_GATEWAY = configure_discord_relay()
87
+
88
+
89
+ def utc_now_iso() -> str:
90
+ return datetime.now(timezone.utc).isoformat()
91
+
92
+
93
+ def parse_dt(value: Optional[str]) -> Optional[datetime]:
94
+ if not value:
95
+ return None
96
+ try:
97
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
98
+ if dt.tzinfo is None:
99
+ dt = dt.replace(tzinfo=timezone.utc)
100
+ return dt.astimezone(timezone.utc)
101
+ except ValueError:
102
+ return None
103
+
104
+
105
+ def age_text(value: Optional[str]) -> str:
106
+ dt = parse_dt(value)
107
+ if not dt:
108
+ return "unknown"
109
+ sec = max(0, int((datetime.now(timezone.utc) - dt).total_seconds()))
110
+ if sec < 60:
111
+ return f"{sec}s ago"
112
+ if sec < 3600:
113
+ return f"{sec // 60}m ago"
114
+ if sec < 86400:
115
+ return f"{sec // 3600}h {(sec % 3600) // 60}m ago"
116
+ return f"{sec // 86400}d ago"
117
+
118
+
119
+ def activity_label(value: Optional[str]) -> str:
120
+ dt = parse_dt(value)
121
+ if not dt:
122
+ return "UNKNOWN"
123
+ age = (datetime.now(timezone.utc) - dt).total_seconds()
124
+ if age <= 8 * 60:
125
+ return "HOT"
126
+ if age <= 20 * 60:
127
+ return "RECENT"
128
+ if age <= 60 * 60:
129
+ return "COOLING"
130
+ return "INACTIVE"
131
+
132
+
133
+ def trunc(s: str, n: int) -> str:
134
+ return s if len(s) <= n else s[: n - 1] + "…"
135
+
136
+
137
+ class Store:
138
+ def __init__(self, path: Path):
139
+ self.path = path
140
+ self.path.parent.mkdir(parents=True, exist_ok=True)
141
+ self._init()
142
+
143
+ def conn(self):
144
+ conn = sqlite3.connect(self.path, timeout=30)
145
+ conn.row_factory = sqlite3.Row
146
+ return conn
147
+
148
+ def _init(self):
149
+ with self.conn() as c:
150
+ c.executescript(
151
+ """
152
+ PRAGMA journal_mode=WAL;
153
+ CREATE TABLE IF NOT EXISTS players (
154
+ id INTEGER PRIMARY KEY,
155
+ guild_id INTEGER NOT NULL,
156
+ label TEXT NOT NULL,
157
+ platform TEXT NOT NULL,
158
+ identifier TEXT NOT NULL,
159
+ handle TEXT,
160
+ tracker_player_id INTEGER,
161
+ alert_channel_id INTEGER,
162
+ enabled INTEGER NOT NULL DEFAULT 1,
163
+ created_at TEXT NOT NULL,
164
+ UNIQUE(guild_id, platform, identifier)
165
+ );
166
+
167
+ CREATE TABLE IF NOT EXISTS snapshots (
168
+ id INTEGER PRIMARY KEY,
169
+ player_id INTEGER NOT NULL,
170
+ observed_at TEXT NOT NULL,
171
+ tracker_updated_at TEXT,
172
+ playlist_json TEXT NOT NULL,
173
+ FOREIGN KEY(player_id) REFERENCES players(id)
174
+ );
175
+ CREATE INDEX IF NOT EXISTS idx_snapshots_player_time
176
+ ON snapshots(player_id, observed_at DESC);
177
+
178
+ CREATE TABLE IF NOT EXISTS matches (
179
+ id INTEGER PRIMARY KEY,
180
+ player_id INTEGER NOT NULL,
181
+ external_id TEXT NOT NULL,
182
+ collected_at TEXT,
183
+ playlist TEXT,
184
+ result TEXT,
185
+ rating INTEGER,
186
+ rating_delta INTEGER,
187
+ tier TEXT,
188
+ division TEXT,
189
+ is_grouped INTEGER,
190
+ matches_played INTEGER,
191
+ wins INTEGER,
192
+ goals INTEGER,
193
+ assists INTEGER,
194
+ saves INTEGER,
195
+ shots INTEGER,
196
+ mvps INTEGER,
197
+ first_seen_at TEXT NOT NULL,
198
+ raw_json TEXT,
199
+ FOREIGN KEY(player_id) REFERENCES players(id),
200
+ UNIQUE(player_id, external_id)
201
+ );
202
+ CREATE INDEX IF NOT EXISTS idx_matches_player_collected
203
+ ON matches(player_id, collected_at DESC);
204
+ """
205
+ )
206
+
207
+ def add_player(self, guild_id: int, label: str, platform: str, identifier: str, channel_id: int) -> int:
208
+ with self.conn() as c:
209
+ c.execute(
210
+ """
211
+ INSERT INTO players(guild_id,label,platform,identifier,alert_channel_id,created_at)
212
+ VALUES(?,?,?,?,?,?)
213
+ ON CONFLICT(guild_id, platform, identifier) DO UPDATE SET
214
+ label=excluded.label,
215
+ alert_channel_id=excluded.alert_channel_id,
216
+ enabled=1
217
+ """,
218
+ (guild_id, label, platform, identifier, channel_id, utc_now_iso()),
219
+ )
220
+ row = c.execute(
221
+ "SELECT id FROM players WHERE guild_id=? AND platform=? AND identifier=?",
222
+ (guild_id, platform, identifier),
223
+ ).fetchone()
224
+ return int(row["id"])
225
+
226
+ def remove_player(self, guild_id: int, query: str) -> bool:
227
+ with self.conn() as c:
228
+ cur = c.execute(
229
+ "DELETE FROM players WHERE guild_id=? AND (label=? COLLATE NOCASE OR identifier=? COLLATE NOCASE)",
230
+ (guild_id, query, query),
231
+ )
232
+ return cur.rowcount > 0
233
+
234
+ def players(self, guild_id: Optional[int] = None):
235
+ with self.conn() as c:
236
+ if guild_id is None:
237
+ return c.execute("SELECT * FROM players WHERE enabled=1 ORDER BY guild_id,label").fetchall()
238
+ return c.execute(
239
+ "SELECT * FROM players WHERE enabled=1 AND guild_id=? ORDER BY label COLLATE NOCASE",
240
+ (guild_id,),
241
+ ).fetchall()
242
+
243
+ def get_player(self, guild_id: int, query: str):
244
+ with self.conn() as c:
245
+ return c.execute(
246
+ """
247
+ SELECT * FROM players
248
+ WHERE guild_id=? AND enabled=1
249
+ AND (label=? COLLATE NOCASE OR handle=? COLLATE NOCASE OR identifier=? COLLATE NOCASE)
250
+ LIMIT 1
251
+ """,
252
+ (guild_id, query, query, query),
253
+ ).fetchone()
254
+
255
+ def update_identity(self, player_id: int, handle: str, tracker_player_id: Optional[int]):
256
+ with self.conn() as c:
257
+ c.execute(
258
+ "UPDATE players SET handle=?, tracker_player_id=? WHERE id=?",
259
+ (handle, tracker_player_id, player_id),
260
+ )
261
+
262
+ def add_snapshot(self, player_id: int, profile) -> None:
263
+ with self.conn() as c:
264
+ c.execute(
265
+ "INSERT INTO snapshots(player_id,observed_at,tracker_updated_at,playlist_json) VALUES(?,?,?,?)",
266
+ (
267
+ player_id,
268
+ utc_now_iso(),
269
+ profile.last_updated.isoformat() if profile.last_updated else None,
270
+ json.dumps(profile.playlists, separators=(",", ":")),
271
+ ),
272
+ )
273
+
274
+ def latest_snapshot(self, player_id: int):
275
+ with self.conn() as c:
276
+ return c.execute(
277
+ "SELECT * FROM snapshots WHERE player_id=? ORDER BY observed_at DESC LIMIT 1",
278
+ (player_id,),
279
+ ).fetchone()
280
+
281
+ def insert_matches(self, player_id: int, matches: list[dict[str, Any]]) -> list[dict[str, Any]]:
282
+ newly_added: list[dict[str, Any]] = []
283
+ with self.conn() as c:
284
+ for m in matches:
285
+ ext = m.get("match_id")
286
+ if not ext:
287
+ continue
288
+ cur = c.execute(
289
+ """
290
+ INSERT OR IGNORE INTO matches(
291
+ player_id,external_id,collected_at,playlist,result,rating,rating_delta,tier,division,
292
+ is_grouped,matches_played,wins,goals,assists,saves,shots,mvps,first_seen_at,raw_json
293
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
294
+ """,
295
+ (
296
+ player_id, ext, m.get("collected_at"), m.get("playlist"), m.get("result"),
297
+ m.get("rating"), m.get("rating_delta"), m.get("tier"), m.get("division"),
298
+ int(bool(m.get("is_grouped"))), m.get("matches_played"), m.get("wins"),
299
+ m.get("goals"), m.get("assists"), m.get("saves"), m.get("shots"), m.get("mvps"),
300
+ utc_now_iso(), json.dumps(m, separators=(",", ":")),
301
+ ),
302
+ )
303
+ if cur.rowcount:
304
+ newly_added.append(m)
305
+ return newly_added
306
+
307
+ def recent_matches(self, player_id: int, limit: int = 10):
308
+ with self.conn() as c:
309
+ return c.execute(
310
+ "SELECT * FROM matches WHERE player_id=? ORDER BY collected_at DESC LIMIT ?",
311
+ (player_id, limit),
312
+ ).fetchall()
313
+
314
+ def latest_match(self, player_id: int):
315
+ rows = self.recent_matches(player_id, 1)
316
+ return rows[0] if rows else None
317
+
318
+
319
+ store = Store(DB_PATH)
320
+ tracker = TrackerClient()
321
+
322
+ intents = discord.Intents.default()
323
+ client = discord.Client(intents=intents)
324
+ tree = app_commands.CommandTree(client)
325
+
326
+ # Runtime state used by the health endpoint.
327
+ runtime = {
328
+ "discord_connected": False,
329
+ "discord_user": None,
330
+ "discord_last_error": None,
331
+ "discord_last_attempt": None,
332
+ "discord_last_connected": None,
333
+ "discord_retry_seconds": None,
334
+ "discord_stage": "idle",
335
+ "discord_stage_since": None,
336
+ }
337
+
338
+
339
+ async def fetch_and_store(player_row, *, seed: bool = False) -> tuple[Any, list[dict[str, Any]]]:
340
+ profile = await asyncio.to_thread(tracker.profile, player_row["platform"], player_row["identifier"])
341
+ sessions_payload = await asyncio.to_thread(tracker.sessions, player_row["platform"], player_row["identifier"])
342
+ matches = tracker.flatten_session_matches(sessions_payload)
343
+
344
+ store.update_identity(player_row["id"], profile.handle, profile.player_id)
345
+ store.add_snapshot(player_row["id"], profile)
346
+ new_matches = store.insert_matches(player_row["id"], matches)
347
+
348
+ # On initial add we ingest history but intentionally do not alert on it.
349
+ return profile, ([] if seed else new_matches)
350
+
351
+
352
+ def profile_embed(player_row, profile, latest_match=None) -> discord.Embed:
353
+ e = discord.Embed(
354
+ title=f"{profile.handle} — Rocket League",
355
+ description=f"{profile.platform.upper()} • Season {profile.season or '?'}",
356
+ color=discord.Color.blurple(),
357
+ timestamp=datetime.now(timezone.utc),
358
+ )
359
+ if profile.avatar_url:
360
+ e.set_thumbnail(url=profile.avatar_url)
361
+
362
+ if latest_match:
363
+ e.add_field(name="Activity", value=f"**{activity_label(latest_match['collected_at'])}** • {age_text(latest_match['collected_at'])}", inline=True)
364
+ e.add_field(name="Last mode", value=latest_match["playlist"] or "Unknown", inline=True)
365
+ if latest_match["rating"] is not None:
366
+ delta = latest_match["rating_delta"]
367
+ ds = f" ({delta:+d})" if delta is not None else ""
368
+ e.add_field(name="Last MMR", value=f"{latest_match['rating']}{ds}", inline=True)
369
+
370
+ ranked = [p for p in profile.playlists if p.get("rating") is not None]
371
+ ranked.sort(key=lambda p: ("Ranked" not in (p.get("name") or ""), p.get("name") or ""))
372
+ for p in ranked[:10]:
373
+ rank = " ".join(x for x in [p.get("tier"), p.get("division")] if x) or "—"
374
+ peak = f" • peak {p['peak_rating']}" if p.get("peak_rating") is not None else ""
375
+ e.add_field(
376
+ name=trunc(p.get("name") or "Playlist", 256),
377
+ value=f"**{p['rating']} MMR** • {rank}{peak}",
378
+ inline=False,
379
+ )
380
+ e.set_footer(text="Tracker collection times can lag actual in-game activity.")
381
+ return e
382
+
383
+
384
+ def match_embed(player_row, match: dict[str, Any]) -> discord.Embed:
385
+ rating = match.get("rating")
386
+ delta = match.get("rating_delta")
387
+ mmr = "Unknown"
388
+ if rating is not None:
389
+ mmr = str(rating)
390
+ if delta is not None:
391
+ mmr += f" ({delta:+d})"
392
+ result = match.get("result") or "activity"
393
+ e = discord.Embed(
394
+ title=f"New RL activity — {player_row['label']}",
395
+ description=f"**{match.get('playlist') or 'Unknown playlist'}** • {result}",
396
+ color=discord.Color.green() if str(result).lower() == "victory" else discord.Color.orange(),
397
+ timestamp=parse_dt(match.get("collected_at")) or datetime.now(timezone.utc),
398
+ )
399
+ e.add_field(name="MMR", value=mmr, inline=True)
400
+ rank = " ".join(x for x in [match.get("tier"), match.get("division")] if x)
401
+ if rank:
402
+ e.add_field(name="Rank", value=rank, inline=True)
403
+ e.add_field(name="Observed", value=age_text(match.get("collected_at")), inline=True)
404
+ stats = []
405
+ for k, label in [("goals", "G"), ("assists", "A"), ("saves", "S"), ("shots", "Shots")]:
406
+ if match.get(k) is not None:
407
+ stats.append(f"{label}: {match[k]}")
408
+ if stats:
409
+ e.add_field(name="Stats", value=" • ".join(stats), inline=False)
410
+ e.set_footer(text="Activity is inferred from Tracker updates; it is not a direct online-status signal.")
411
+ return e
412
+
413
+
414
+ @client.event
415
+ async def on_ready():
416
+ runtime["discord_connected"] = True
417
+ runtime["discord_user"] = str(client.user) if client.user else None
418
+ runtime["discord_last_connected"] = utc_now_iso()
419
+ runtime["discord_last_error"] = None
420
+ runtime["discord_retry_seconds"] = None
421
+ log.info("Logged in as %s (%s)", client.user, client.user.id if client.user else "?")
422
+ if not watcher.is_running():
423
+ watcher.change_interval(seconds=POLL_SECONDS)
424
+ watcher.start()
425
+
426
+
427
+ @client.event
428
+ async def on_disconnect():
429
+ runtime["discord_connected"] = False
430
+
431
+
432
+ @tree.command(name="track_add", description="Track a Rocket League account and alert this channel on new activity")
433
+ @app_commands.describe(platform="steam, epic, xbox, or psn", identifier="Tracker-compatible account identifier", label="Short name shown by the bot")
434
+ async def track_add(interaction: discord.Interaction, platform: str, identifier: str, label: Optional[str] = None):
435
+ if not interaction.guild_id or not interaction.channel_id:
436
+ await interaction.response.send_message("Use this command inside a server channel.", ephemeral=True)
437
+ return
438
+ platform = platform.strip().lower()
439
+ aliases = {"xbox": "xbl", "playstation": "psn", "epicgames": "epic"}
440
+ platform = aliases.get(platform, platform)
441
+ if platform not in {"steam", "epic", "xbl", "psn"}:
442
+ await interaction.response.send_message("Platform must be `steam`, `epic`, `xbl`, or `psn`.", ephemeral=True)
443
+ return
444
+
445
+ await interaction.response.defer(ephemeral=True)
446
+ row_id = store.add_player(interaction.guild_id, label or identifier, platform, identifier.strip(), interaction.channel_id)
447
+ row = store.get_player(interaction.guild_id, label or identifier)
448
+ if not row:
449
+ # If label changed on an existing record, fetch by identifier.
450
+ row = store.get_player(interaction.guild_id, identifier.strip())
451
+ try:
452
+ profile, _ = await fetch_and_store(row, seed=True)
453
+ except TrackerError as exc:
454
+ store.remove_player(interaction.guild_id, identifier.strip())
455
+ await interaction.followup.send(f"Could not load that Tracker profile: `{exc}`", ephemeral=True)
456
+ return
457
+
458
+ await interaction.followup.send(
459
+ f"Tracking **{profile.handle}** as **{label or profile.handle}**. New activity will be posted in <#{interaction.channel_id}>. Historical sessions were seeded without alerts.",
460
+ ephemeral=True,
461
+ )
462
+
463
+
464
+ @tree.command(name="track_remove", description="Stop tracking a Rocket League account")
465
+ async def track_remove(interaction: discord.Interaction, player: str):
466
+ if not interaction.guild_id:
467
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
468
+ return
469
+ ok = store.remove_player(interaction.guild_id, player.strip())
470
+ await interaction.response.send_message("Removed." if ok else "No matching tracked player.", ephemeral=True)
471
+
472
+
473
+ @tree.command(name="track_list", description="List Rocket League accounts tracked in this server")
474
+ async def track_list(interaction: discord.Interaction):
475
+ if not interaction.guild_id:
476
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
477
+ return
478
+ rows = store.players(interaction.guild_id)
479
+ if not rows:
480
+ await interaction.response.send_message("No players are being tracked yet.", ephemeral=True)
481
+ return
482
+ lines = []
483
+ for r in rows:
484
+ last = store.latest_match(r["id"])
485
+ status = activity_label(last["collected_at"]) if last else "UNKNOWN"
486
+ mode = last["playlist"] if last else "no session data"
487
+ lines.append(f"• **{r['label']}** — `{r['platform']}` / `{r['identifier']}` — **{status}** — {mode}")
488
+ await interaction.response.send_message("\n".join(lines), ephemeral=True)
489
+
490
+
491
+ @tree.command(name="rl_player", description="Show current MMRs and recent activity for a tracked player")
492
+ async def rl_player(interaction: discord.Interaction, player: str):
493
+ if not interaction.guild_id:
494
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
495
+ return
496
+ row = store.get_player(interaction.guild_id, player.strip())
497
+ if not row:
498
+ await interaction.response.send_message("Player not found. Use `/track_list`.", ephemeral=True)
499
+ return
500
+ await interaction.response.defer()
501
+ try:
502
+ profile, _ = await fetch_and_store(row, seed=False)
503
+ except TrackerError as exc:
504
+ await interaction.followup.send(f"Tracker request failed: `{exc}`")
505
+ return
506
+ latest = store.latest_match(row["id"])
507
+ await interaction.followup.send(embed=profile_embed(row, profile, latest))
508
+
509
+
510
+ @tree.command(name="rl_recent", description="Show a tracked player's recent recorded matches/activity")
511
+ @app_commands.describe(count="Number of entries to show (1-10)")
512
+ async def rl_recent(interaction: discord.Interaction, player: str, count: app_commands.Range[int, 1, 10] = 5):
513
+ if not interaction.guild_id:
514
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
515
+ return
516
+ row = store.get_player(interaction.guild_id, player.strip())
517
+ if not row:
518
+ await interaction.response.send_message("Player not found. Use `/track_list`.", ephemeral=True)
519
+ return
520
+ matches = store.recent_matches(row["id"], count)
521
+ if not matches:
522
+ await interaction.response.send_message("No session records stored yet.")
523
+ return
524
+ lines = []
525
+ for m in matches:
526
+ mmr = "?" if m["rating"] is None else str(m["rating"])
527
+ if m["rating_delta"] is not None:
528
+ mmr += f" ({m['rating_delta']:+d})"
529
+ lines.append(f"**{m['playlist'] or 'Unknown'}** — {m['result'] or 'activity'} — `{mmr} MMR` — {age_text(m['collected_at'])}")
530
+ e = discord.Embed(title=f"Recent activity — {row['label']}", description="\n".join(lines), color=discord.Color.blurple())
531
+ await interaction.response.send_message(embed=e)
532
+
533
+
534
+ @tree.command(name="rl_refresh", description="Refresh all tracked players in this server now")
535
+ async def rl_refresh(interaction: discord.Interaction):
536
+ if not interaction.guild_id:
537
+ await interaction.response.send_message("Use this in a server.", ephemeral=True)
538
+ return
539
+ await interaction.response.defer(ephemeral=True)
540
+ rows = store.players(interaction.guild_id)
541
+ ok = 0
542
+ errors = []
543
+ for row in rows:
544
+ try:
545
+ await fetch_and_store(row, seed=False)
546
+ ok += 1
547
+ except TrackerError as exc:
548
+ errors.append(f"{row['label']}: {exc}")
549
+ msg = f"Refreshed {ok}/{len(rows)} tracked players."
550
+ if errors:
551
+ msg += "\n" + "\n".join(f"• {trunc(e, 160)}" for e in errors[:5])
552
+ await interaction.followup.send(msg, ephemeral=True)
553
+
554
+
555
+ @tasks.loop(seconds=120)
556
+ async def watcher():
557
+ rows = store.players()
558
+ for row in rows:
559
+ try:
560
+ profile, new_matches = await fetch_and_store(row, seed=False)
561
+ if not new_matches:
562
+ continue
563
+ # Tracker may reveal several historical/grouped rows in one refresh. Alert only the
564
+ # newest few so one cache refresh cannot flood a channel.
565
+ new_matches.sort(key=lambda m: m.get("collected_at") or "", reverse=True)
566
+ channel = client.get_channel(row["alert_channel_id"])
567
+ if channel is None:
568
+ try:
569
+ channel = await client.fetch_channel(row["alert_channel_id"])
570
+ except discord.DiscordException:
571
+ log.warning("Could not resolve channel %s", row["alert_channel_id"])
572
+ continue
573
+ for match in new_matches[:3]:
574
+ try:
575
+ await channel.send(embed=match_embed(row, match))
576
+ except discord.DiscordException as exc:
577
+ log.warning("Failed sending alert for %s: %s", row["label"], exc)
578
+ except TrackerError as exc:
579
+ log.warning("Tracker error for %s: %s", row["label"], exc)
580
+ except Exception:
581
+ log.exception("Unexpected watcher error for %s", row["label"])
582
+ await asyncio.sleep(1)
583
+
584
+
585
+ @watcher.before_loop
586
+ async def before_watcher():
587
+ await client.wait_until_ready()
588
+
589
+
590
+ # Hugging Face Docker Spaces expect an HTTP service. This tiny endpoint also gives
591
+ # you a convenient uptime/health check while the Discord bot runs in the same process.
592
+ health = FastAPI(title="Rocket Watch Health")
593
+
594
+
595
+ @health.get("/")
596
+ def root():
597
+ return {
598
+ "ok": True,
599
+ "service": "rocket-watch-discord-bot",
600
+ "discord_connected": runtime["discord_connected"],
601
+ "discord_user": runtime["discord_user"],
602
+ "discord_last_error": runtime["discord_last_error"],
603
+ "discord_last_attempt": runtime["discord_last_attempt"],
604
+ "discord_last_connected": runtime["discord_last_connected"],
605
+ "discord_retry_seconds": runtime["discord_retry_seconds"],
606
+ "discord_stage": runtime["discord_stage"],
607
+ "discord_stage_since": runtime["discord_stage_since"],
608
+ "discord_relay_enabled": bool(DISCORD_RELAY_GATEWAY),
609
+ "discord_relay_origin": DISCORD_RELAY_ORIGIN or None,
610
+ "tracked_players": len(store.players()),
611
+ "poll_seconds": POLL_SECONDS,
612
+ }
613
+
614
+
615
+ @health.get("/health")
616
+ def healthcheck():
617
+ return {
618
+ "ok": True,
619
+ "http": "online",
620
+ "discord": "connected" if runtime["discord_connected"] else "disconnected",
621
+ "discord_last_error": runtime["discord_last_error"],
622
+ "discord_stage": runtime["discord_stage"],
623
+ "discord_stage_since": runtime["discord_stage_since"],
624
+ }
625
+
626
+
627
+ def set_discord_stage(stage: str) -> None:
628
+ runtime["discord_stage"] = stage
629
+ runtime["discord_stage_since"] = utc_now_iso()
630
+
631
+
632
+ @health.get("/debug/discord")
633
+ async def debug_discord():
634
+ """Test both direct Discord and the configured Deno relay without a token."""
635
+
636
+ async def probe(label: str, url: str):
637
+ started = time.monotonic()
638
+ try:
639
+ timeout = aiohttp.ClientTimeout(total=10, connect=5, sock_connect=5, sock_read=5)
640
+ async with aiohttp.ClientSession(timeout=timeout) as session:
641
+ async with session.get(url) as resp:
642
+ body = await resp.text()
643
+ return {
644
+ "label": label,
645
+ "url": url.replace(DISCORD_RELAY_SECRET, "***") if DISCORD_RELAY_SECRET else url,
646
+ "status": resp.status,
647
+ "body": body[:300],
648
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
649
+ }
650
+ except Exception as exc:
651
+ return {
652
+ "label": label,
653
+ "url": url.replace(DISCORD_RELAY_SECRET, "***") if DISCORD_RELAY_SECRET else url,
654
+ "error": f"{type(exc).__name__}: {exc}",
655
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
656
+ }
657
+
658
+ dns = None
659
+ try:
660
+ infos = await asyncio.wait_for(
661
+ asyncio.get_running_loop().run_in_executor(
662
+ None, lambda: socket.getaddrinfo("discord.com", 443, type=socket.SOCK_STREAM)
663
+ ),
664
+ timeout=5,
665
+ )
666
+ dns = sorted({item[4][0] for item in infos})[:6]
667
+ except Exception as exc:
668
+ dns = f"{type(exc).__name__}: {exc}"
669
+
670
+ result = {
671
+ "discord_dns": dns,
672
+ "direct": await probe("direct", "https://discord.com/api/v10/gateway"),
673
+ "relay_enabled": bool(DISCORD_RELAY_GATEWAY),
674
+ }
675
+ if DISCORD_RELAY_GATEWAY:
676
+ relay_url = f"{DISCORD_RELAY_ORIGIN}/p/{DISCORD_RELAY_SECRET}/api/v10/gateway"
677
+ result["relay"] = await probe("deno-relay", relay_url)
678
+ return result
679
+
680
+
681
+ async def sync_commands():
682
+ """Sync slash commands after Discord is actually connected."""
683
+ try:
684
+ if GUILD_ID:
685
+ guild = discord.Object(id=int(GUILD_ID))
686
+ tree.copy_global_to(guild=guild)
687
+ synced = await tree.sync(guild=guild)
688
+ log.info("Synced %d slash commands to guild %s", len(synced), GUILD_ID)
689
+ else:
690
+ synced = await tree.sync()
691
+ log.info("Synced %d global slash commands", len(synced))
692
+ except Exception:
693
+ log.exception("Slash-command sync failed")
694
+
695
+
696
+ def rebuild_discord_client() -> None:
697
+ """Replace the Discord client with a brand-new HTTP/WebSocket stack.
698
+
699
+ discord.py can close its internal aiohttp session after a failed login.
700
+ Client.clear() resets cache/state but does not make that closed session usable
701
+ again, so retries must use a fresh Client instance.
702
+ """
703
+ global client, tree
704
+
705
+ # Preserve the application-command objects already registered on the current
706
+ # tree, then attach them to a fresh tree/client.
707
+ commands = list(tree.get_commands())
708
+
709
+ new_client = discord.Client(intents=intents)
710
+ new_tree = app_commands.CommandTree(new_client)
711
+ for command in commands:
712
+ new_tree.add_command(command)
713
+
714
+ # Re-register event callbacks on the new Client. The callbacks intentionally
715
+ # reference the module-level `client`, which is rebound below.
716
+ new_client.event(on_ready)
717
+ new_client.event(on_disconnect)
718
+
719
+ client = new_client
720
+ tree = new_tree
721
+
722
+
723
+ async def discord_supervisor():
724
+ """Keep Discord failures isolated from the Hugging Face HTTP service.
725
+
726
+ The Space listens only on $PORT (7860 by default). Discord uses outbound
727
+ HTTPS/WSS and therefore does not need another local listening port. Every
728
+ retry uses a completely fresh discord.Client so a failed aiohttp session
729
+ cannot poison future attempts.
730
+ """
731
+ delay = 5
732
+ max_delay = 300
733
+ first_attempt = True
734
+
735
+ while True:
736
+ runtime["discord_last_attempt"] = utc_now_iso()
737
+ runtime["discord_retry_seconds"] = None
738
+
739
+ if not first_attempt:
740
+ rebuild_discord_client()
741
+ first_attempt = False
742
+
743
+ connect_task = None
744
+ try:
745
+ log.info("Connecting to Discord with a fresh client...")
746
+
747
+ set_discord_stage("rest_login")
748
+ await asyncio.wait_for(
749
+ client.login(DISCORD_BOT_TOKEN),
750
+ timeout=DISCORD_LOGIN_TIMEOUT,
751
+ )
752
+ log.info("Discord REST login succeeded as %s", client.user)
753
+
754
+ set_discord_stage("command_sync")
755
+ await asyncio.wait_for(sync_commands(), timeout=20)
756
+
757
+ # client.connect() is intentionally long-running. Start it separately,
758
+ # then require READY within a bounded amount of time. This distinguishes
759
+ # a successful REST login from a Gateway/WebSocket failure.
760
+ set_discord_stage("gateway_connect")
761
+ connect_task = asyncio.create_task(client.connect(reconnect=True), name="discord-gateway")
762
+ await asyncio.wait_for(client.wait_until_ready(), timeout=DISCORD_READY_TIMEOUT)
763
+ set_discord_stage("connected")
764
+ log.info("Discord gateway is READY")
765
+
766
+ # Stay attached to the gateway until discord.py returns or fails.
767
+ await connect_task
768
+
769
+ runtime["discord_connected"] = False
770
+ runtime["discord_last_error"] = "Discord connection closed"
771
+ set_discord_stage("disconnected")
772
+ delay = 5
773
+ except asyncio.CancelledError:
774
+ try:
775
+ if not client.is_closed():
776
+ await client.close()
777
+ finally:
778
+ raise
779
+ except asyncio.TimeoutError:
780
+ stage = runtime.get("discord_stage") or "unknown"
781
+ timeout_used = DISCORD_LOGIN_TIMEOUT if stage == "rest_login" else DISCORD_READY_TIMEOUT
782
+ runtime["discord_connected"] = False
783
+ runtime["discord_last_error"] = f"Timeout while in {stage} after {timeout_used}s"
784
+ log.error("Discord timed out during %s after %ss", stage, timeout_used)
785
+ except discord.LoginFailure as exc:
786
+ runtime["discord_connected"] = False
787
+ runtime["discord_last_error"] = f"Login failure: {exc}"
788
+ delay = 300
789
+ log.error("Discord rejected the bot token: %s", exc)
790
+ except Exception as exc:
791
+ runtime["discord_connected"] = False
792
+ runtime["discord_last_error"] = f"{type(exc).__name__}: {exc}"
793
+ log.exception("Discord connection attempt failed")
794
+
795
+ # Best-effort cleanup. We never reuse this instance after a failure.
796
+ if connect_task is not None and not connect_task.done():
797
+ connect_task.cancel()
798
+ try:
799
+ await connect_task
800
+ except (asyncio.CancelledError, Exception):
801
+ pass
802
+ try:
803
+ if not client.is_closed():
804
+ await client.close()
805
+ except Exception:
806
+ log.debug("Discord client cleanup failed", exc_info=True)
807
+
808
+ runtime["discord_retry_seconds"] = delay
809
+ set_discord_stage("retry_wait")
810
+ log.warning("Discord unavailable; retrying with a fresh client in %ss", delay)
811
+ await asyncio.sleep(delay)
812
+ delay = min(delay * 2, max_delay)
813
+
814
+
815
+ async def main():
816
+ if not DISCORD_BOT_TOKEN:
817
+ raise RuntimeError(
818
+ "DISCORD_BOT_TOKEN is not set. Add it as a Hugging Face Space secret or environment variable."
819
+ )
820
+
821
+ # Hugging Face exposes one HTTP port. Uvicorn owns that port; Discord runs as
822
+ # an outbound background connection and never binds a local port.
823
+ port = int(os.getenv("PORT", "7860"))
824
+ config = uvicorn.Config(
825
+ health,
826
+ host="0.0.0.0",
827
+ port=port,
828
+ log_level=os.getenv("UVICORN_LOG_LEVEL", "info").lower(),
829
+ lifespan="on",
830
+ )
831
+ server = uvicorn.Server(config)
832
+
833
+ discord_task = asyncio.create_task(discord_supervisor(), name="discord-supervisor")
834
+ try:
835
+ await server.serve()
836
+ finally:
837
+ discord_task.cancel()
838
+ try:
839
+ await discord_task
840
+ except asyncio.CancelledError:
841
+ pass
842
+ if not client.is_closed():
843
+ await client.close()
844
+
845
+
846
+ if __name__ == "__main__":
847
+ 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,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
12
+
13
+ class TrackerError(RuntimeError):
14
+ pass
15
+
16
+
17
+ def _dt(value: Optional[str]) -> Optional[datetime]:
18
+ if not value:
19
+ return None
20
+ try:
21
+ d = datetime.fromisoformat(value.replace("Z", "+00:00"))
22
+ if d.tzinfo is None:
23
+ d = d.replace(tzinfo=timezone.utc)
24
+ return d.astimezone(timezone.utc)
25
+ except ValueError:
26
+ return None
27
+
28
+
29
+ def _stat_value(stats: Dict[str, Any], name: str, default=None):
30
+ return (stats.get(name) or {}).get("value", default)
31
+
32
+
33
+ @dataclass
34
+ class PlayerProfile:
35
+ platform: str
36
+ identifier: str
37
+ handle: str
38
+ player_id: Optional[int]
39
+ last_updated: Optional[datetime]
40
+ season: Optional[int]
41
+ avatar_url: Optional[str]
42
+ lifetime: Dict[str, Any]
43
+ playlists: List[Dict[str, Any]]
44
+ raw: Dict[str, Any]
45
+
46
+
47
+ class TrackerClient:
48
+ """Best-effort client for Tracker web endpoints observed in supplied samples.
49
+
50
+ This intentionally does not solve/bypass Cloudflare or Warden challenges. A 401/403
51
+ is returned to the bot as an error instead.
52
+ """
53
+
54
+ def __init__(self, timeout: int = 20):
55
+ self.session = requests.Session()
56
+ self.timeout = timeout
57
+ self.session.headers.update(
58
+ {
59
+ "Accept": "application/json, text/plain, */*",
60
+ "Origin": "https://rocketleague.tracker.network",
61
+ "Referer": "https://rocketleague.tracker.network/",
62
+ "User-Agent": os.getenv(
63
+ "TRACKER_USER_AGENT",
64
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
65
+ "(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
66
+ ),
67
+ }
68
+ )
69
+ cookie = os.getenv("TRACKER_COOKIE", "").strip()
70
+ if cookie:
71
+ self.session.headers["Cookie"] = cookie
72
+
73
+ def _get(self, path: str, params: Optional[dict] = None) -> Dict[str, Any]:
74
+ url = f"{BASE_WEB}{path}"
75
+ try:
76
+ r = self.session.get(url, params=params, timeout=self.timeout)
77
+ except requests.RequestException as exc:
78
+ raise TrackerError(f"Network error contacting Tracker: {exc}") from exc
79
+ if r.status_code in (401, 403):
80
+ raise TrackerError(
81
+ f"HTTP {r.status_code}; Tracker's web endpoint may be protected or require browser state."
82
+ )
83
+ try:
84
+ r.raise_for_status()
85
+ except requests.HTTPError as exc:
86
+ raise TrackerError(f"HTTP {r.status_code} for {r.url}") from exc
87
+ try:
88
+ return r.json()
89
+ except ValueError as exc:
90
+ raise TrackerError("Tracker returned non-JSON content") from exc
91
+
92
+ def profile(self, platform: str, identifier: str) -> PlayerProfile:
93
+ payload = self._get(f"/api/v2/rocket-league/standard/profile/{platform}/{identifier}")
94
+ data = payload.get("data") or {}
95
+ pinfo = data.get("platformInfo") or {}
96
+ meta = data.get("metadata") or {}
97
+ segments = data.get("segments") or []
98
+
99
+ lifetime: Dict[str, Any] = {}
100
+ playlists: List[Dict[str, Any]] = []
101
+ for seg in segments:
102
+ stats = seg.get("stats") or {}
103
+ if seg.get("type") == "overview":
104
+ lifetime = {k: (v or {}).get("value") for k, v in stats.items()}
105
+ elif seg.get("type") == "playlist":
106
+ attrs = seg.get("attributes") or {}
107
+ smeta = seg.get("metadata") or {}
108
+ tier = stats.get("tier") or {}
109
+ div = stats.get("division") or {}
110
+ rating = stats.get("rating") or {}
111
+ peak = stats.get("peakRating") or {}
112
+ playlists.append(
113
+ {
114
+ "playlist_id": attrs.get("playlistId"),
115
+ "name": smeta.get("name", f"Playlist {attrs.get('playlistId')}"),
116
+ "season": attrs.get("season"),
117
+ "rating": rating.get("value"),
118
+ "tier": (rating.get("metadata") or {}).get("tierName")
119
+ or (tier.get("metadata") or {}).get("name"),
120
+ "division": (div.get("metadata") or {}).get("name"),
121
+ "matches_played": _stat_value(stats, "matchesPlayed"),
122
+ "win_streak": _stat_value(stats, "winStreak"),
123
+ "peak_rating": peak.get("value"),
124
+ }
125
+ )
126
+
127
+ return PlayerProfile(
128
+ platform=pinfo.get("platformSlug") or platform,
129
+ identifier=pinfo.get("platformUserIdentifier") or identifier,
130
+ handle=pinfo.get("platformUserHandle") or identifier,
131
+ player_id=meta.get("playerId"),
132
+ last_updated=_dt((meta.get("lastUpdated") or {}).get("value")),
133
+ season=meta.get("currentSeason"),
134
+ avatar_url=pinfo.get("avatarUrl"),
135
+ lifetime=lifetime,
136
+ playlists=playlists,
137
+ raw=payload,
138
+ )
139
+
140
+ def sessions(self, platform: str, identifier: str) -> Dict[str, Any]:
141
+ return self._get(f"/api/v2/rocket-league/standard/profile/{platform}/{identifier}/sessions")
142
+
143
+ def mmr_history(self, player_id: int) -> Dict[str, Any]:
144
+ return self._get(f"/api/v1/rocket-league/player-history/mmr/{player_id}")
145
+
146
+ def overview_history(self, player_id: int) -> Dict[str, Any]:
147
+ return self._get(f"/api/v1/rocket-league/player-history/overview/{player_id}", params={"type": "graph"})
148
+
149
+ def aggregated(self, platform: str, identifier: str, local_offset: int = 0) -> Dict[str, Any]:
150
+ return self._get(
151
+ f"/api/v1/rocket-league/matches/{platform}/{identifier}/aggregated",
152
+ params={"localOffset": local_offset},
153
+ )
154
+
155
+ @staticmethod
156
+ def flatten_session_matches(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
157
+ out: List[Dict[str, Any]] = []
158
+ for session in ((payload.get("data") or {}).get("items") or []):
159
+ for m in session.get("matches") or []:
160
+ meta = m.get("metadata") or {}
161
+ stats = m.get("stats") or {}
162
+ r = stats.get("rating") or {}
163
+ rmeta = r.get("metadata") or {}
164
+ out.append(
165
+ {
166
+ "match_id": m.get("id"),
167
+ "collected_at": meta.get("dateCollected"),
168
+ "playlist": meta.get("playlist"),
169
+ "result": meta.get("result"),
170
+ "is_grouped": bool(meta.get("isGrouped")),
171
+ "rating": r.get("value"),
172
+ "rating_delta": rmeta.get("ratingDelta"),
173
+ "tier": rmeta.get("tier"),
174
+ "division": rmeta.get("division"),
175
+ "matches_played": _stat_value(stats, "matchesPlayed"),
176
+ "wins": _stat_value(stats, "wins"),
177
+ "goals": _stat_value(stats, "goals"),
178
+ "assists": _stat_value(stats, "assists"),
179
+ "saves": _stat_value(stats, "saves"),
180
+ "shots": _stat_value(stats, "shots"),
181
+ "mvps": _stat_value(stats, "mvps"),
182
+ }
183
+ )
184
+ out.sort(key=lambda x: x.get("collected_at") or "", reverse=True)
185
+ return out