wuhp commited on
Commit
ddbb768
·
verified ·
1 Parent(s): a896c77

Delete main.py

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