using System.Net.WebSockets; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Mvc; using System.Collections.Concurrent; using Newtonsoft.Json; namespace CampusLiveSource.Controllers { [ApiController] [Route("Notifications")] public class NotificationsController : Controller { public static ConcurrentDictionary WebSockets { get; } = new(); // viewer -> the OTHER players this connection asked for realtime updates about, via the // hub's SubscribeToPlayers / UnsubscribeFrom invocations. The 2025 client subscribes to // everyone it is displaying - friends list, the in-room player list, party members - // and expects PresenceUpdate / AccountUpdate frames for them. This used to be acked and // forgotten, so only FRIENDS ever received a presence push and nothing else updated live. public static ConcurrentDictionary> Subscriptions { get; } = new(); /// Every connected viewer that subscribed to . public static List SubscribersOf(ulong subjectId) { var result = new List(); foreach (var kv in Subscriptions) { lock (kv.Value) { if (kv.Value.Contains(subjectId)) result.Add(kv.Key); } } return result; } // SignalR gives no schema for the argument, so accept every plausible spelling: // [{PlayerIds:[1,2]}] / [{playerIds:[..]}] (what this client sends), [[1,2]], and 1,2. // null = unreadable (never treated as "nobody"). private static List? ParsePlayerIds(JsonElement root) { if (!root.TryGetProperty("arguments", out var args) || args.ValueKind != JsonValueKind.Array) return null; var ids = new List(); bool any = false; foreach (var arg in args.EnumerateArray()) { if (arg.ValueKind == JsonValueKind.Object) { foreach (var prop in arg.EnumerateObject()) { if (!prop.Name.Equals("PlayerIds", StringComparison.OrdinalIgnoreCase)) continue; if (prop.Value.ValueKind != JsonValueKind.Array) return null; any = true; foreach (var v in prop.Value.EnumerateArray()) if (v.ValueKind == JsonValueKind.Number && v.TryGetUInt64(out var id)) ids.Add(id); } } else if (arg.ValueKind == JsonValueKind.Array) { any = true; foreach (var v in arg.EnumerateArray()) if (v.ValueKind == JsonValueKind.Number && v.TryGetUInt64(out var id)) ids.Add(id); } else if (arg.ValueKind == JsonValueKind.Number && arg.TryGetUInt64(out var single)) { any = true; ids.Add(single); } } return any ? ids : null; } // =========================================================== // Negotiation endpoint // =========================================================== [HttpPost("hub/v1/negotiate")] public ContentResult Negotiate() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var response = new { negotiateVersion = 0, connectionId = account.Id.ToString(), availableTransports = new[] { new { transport = "WebSockets", transferFormats = new[] { "Text", "Binary" } } } }; return JsonResult(response); } // =========================================================== // Notification preferences/config // =========================================================== [HttpGet("preferences")] public IActionResult GetPreferences() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return Ok(new { mutedCategories = Array.Empty() }); } // The caller's CRM (campaign/messaging) config, fetched at every login. An OBJECT, not // an envelope and not a list: the client parses it as a config object, and the `[]` // this answered before failed that parse ("Malformed Response: '[]'" on every boot - // Player.log). Empty rather than invented, like the reference: nothing here runs // campaigns, and made-up keys would switch on messaging paths with nothing behind them. [HttpGet("crm/me/config/v3")] public ContentResult GetCRMConfig() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return new ContentResult { Content = "{}", ContentType = "application/json", StatusCode = 200 }; } [HttpGet("accounts/{id}/receives/GameplayInvites")] public ContentResult GetGameplayInvites(ulong id) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return JsonResult(true); } [HttpGet("config/categories")] public IActionResult GetCategories() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "NotiConfigCategories.json"); if (!System.IO.File.Exists(path)) { Console.WriteLine("Warning: Notification config json file not found, returning []"); return Ok(new { results = Array.Empty(), totalResults = 0 }); } return PhysicalFile(path, "application/json"); } // =========================================================== // WebSocket handler // =========================================================== [HttpGet("hub/v1")] public async Task HandleHub([FromQuery] string id) { if (!HttpContext.WebSockets.IsWebSocketRequest) { HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return; } if (!ulong.TryParse(id, out var playerId)) { HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return; } using var socket = await HttpContext.WebSockets.AcceptWebSocketAsync(); Console.WriteLine($"[WebSocket] Player {playerId} connected."); await HandleConnectionAsync(playerId, socket); } private static async Task HandleConnectionAsync(ulong playerId, WebSocket socket) { using var pingCts = new CancellationTokenSource(); try { await CloseExistingConnection(playerId); WebSockets[playerId] = socket; await SendHandshakeAsync(socket); // Realtime presence: this player just came ONLINE - tell their online friends so // their friends lists flip to online instantly instead of on the next poll. Campus_live_rewrite.api.server.Notifications.FireAndForget( Campus_live_rewrite.api.server.Notifications.PushPresenceToFriends(playerId), $"presence-online {playerId}"); // Start ping loop _ = Task.Run(() => PingLoopAsync(socket, pingCts.Token)); var buffer = new byte[4096]; while (socket.State == WebSocketState.Open) { var result = await socket.ReceiveAsync(buffer, CancellationToken.None); if (result.MessageType == WebSocketMessageType.Close) break; var message = Encoding.UTF8.GetString(buffer, 0, result.Count).TrimEnd('\x1e'); Console.WriteLine($"Player ({playerId}) sent {message}"); await HandleClientMessageAsync(playerId, socket, message); } } catch (Exception ex) { Console.WriteLine($"[Error:{playerId}] {ex.Message}"); } finally { pingCts.Cancel(); WebSockets.TryRemove(playerId, out _); Subscriptions.TryRemove(playerId, out _); Campus_live_rewrite.api.dbs.HeartbeatDB.StampLastSeen(playerId); // "last online" = when the socket closed // Realtime presence: the socket is gone, so this player now reads OFFLINE - tell // their online friends so their friends lists flip to offline instantly (the socket // is the ground truth GetPresenceForList gates on). Campus_live_rewrite.api.server.Notifications.FireAndForget( Campus_live_rewrite.api.server.Notifications.PushPresenceToFriends(playerId), $"presence-offline {playerId}"); if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived) { await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing connection", CancellationToken.None); } Console.WriteLine($"[WebSocket] Player {playerId} disconnected."); } } private static async Task HandleClientMessageAsync(ulong playerId, WebSocket socket, string message) { try { using var doc = JsonDocument.Parse(message); var root = doc.RootElement; if (!root.TryGetProperty("type", out var typeProp) || typeProp.GetInt32() != 1) return; string target = root.TryGetProperty("target", out var t) ? t.GetString() ?? "" : ""; string? invocationId = root.TryGetProperty("invocationId", out var inv) ? inv.GetString() : null; async Task Complete(object? result, string? error = null) { if (string.IsNullOrEmpty(invocationId)) return; if (error != null) await SendJsonAsync(socket, new { type = 3, invocationId, error }); else await SendJsonAsync(socket, new { type = 3, invocationId, result }); } switch (target) { case "SubscribeToPlayers": { var ids = ParsePlayerIds(root); if (ids == null) { Console.WriteLine($"[hub] {playerId}: unreadable SubscribeToPlayers argument - ignored"); await Complete(null); break; } var set = Subscriptions.GetOrAdd(playerId, _ => new HashSet()); var added = new List(); lock (set) { foreach (var id in ids) if (id != playerId && set.Add(id)) added.Add(id); } await Complete(null); // Send the current state of every NEW subject straight away, so a list renders // live from the moment it opens instead of after that player's next change. foreach (var id in added) await Campus_live_rewrite.api.server.Notifications.PushPresenceTo(playerId, id); Console.WriteLine($"[hub] {playerId}: subscribed to {ids.Count} player(s), {added.Count} new"); break; } case "UnsubscribeFrom": case "UnsubscribeFromPlayers": { var ids = ParsePlayerIds(root); if (ids != null && Subscriptions.TryGetValue(playerId, out var set)) lock (set) { foreach (var id in ids) set.Remove(id); } await Complete(null); break; } case "GetSubscriptions": { ulong[] current = Array.Empty(); if (Subscriptions.TryGetValue(playerId, out var set)) lock (set) current = set.ToArray(); await Complete(current); break; } default: // A hub method we don't implement is a client expectation we've missed. Console.WriteLine($"[hub] {playerId}: unknown invocation target '{target}'"); await Complete(null, $"Unknown method '{target}'"); break; } } catch (Exception ex) { Console.WriteLine($"[ParseError] {ex.Message}"); } } private static async Task SendHandshakeAsync(WebSocket socket) { var handshake = new { protocol = "json", version = 1 }; await SendJsonAsync(socket, handshake); } private static async Task PingLoopAsync(WebSocket socket, CancellationToken token) { try { while (!token.IsCancellationRequested && socket.State == WebSocketState.Open) { var ping = new { type = 6 }; await SendJsonAsync(socket, ping); await Task.Delay(10000, token); } } catch (TaskCanceledException) { } catch (Exception ex) { Console.WriteLine($"[PingError] {ex.Message}"); } } private static async Task CloseExistingConnection(ulong playerId) { if (WebSockets.TryRemove(playerId, out var oldSocket) && oldSocket.State == WebSocketState.Open) { await oldSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Replaced by new connection", CancellationToken.None); Console.WriteLine($"[WebSocket] Closed existing connection for {playerId}"); } } private static async Task SendJsonAsync(WebSocket socket, object obj) { var json = JsonConvert.SerializeObject(obj) + "\x1e"; var bytes = Encoding.UTF8.GetBytes(json); await socket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None); } private static ContentResult JsonResult(object data) => new() { Content = JsonConvert.SerializeObject(data), ContentType = "application/json", StatusCode = 200 }; } }