wuhp commited on
Commit
cf1db08
·
verified ·
1 Parent(s): 0ee6adc

Delete main.py

Browse files
Files changed (1) hide show
  1. main.py +0 -847
main.py DELETED
@@ -1,847 +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
- 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())