Files
2026-10-14 06:59:51 +02:00

404 lines
18 KiB
C#

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<ulong, WebSocket> WebSockets { get; } = new();
// Set from Program.cs to ApplicationStopping. Hub receive loops end on it, so a restart
// doesn't sit out the full 30 s HostOptions.ShutdownTimeout waiting for every connected
// game to hang up (each deploy was ~30 s of "connection refused" behind Caddy).
public static CancellationToken Stopping { get; set; }
// 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<ulong, HashSet<ulong>> Subscriptions { get; } = new();
/// <summary>Every connected viewer that subscribed to <paramref name="subjectId"/>.</summary>
public static List<ulong> SubscribersOf(ulong subjectId)
{
var result = new List<ulong>();
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<ulong>? ParsePlayerIds(JsonElement root)
{
if (!root.TryGetProperty("arguments", out var args) || args.ValueKind != JsonValueKind.Array) return null;
var ids = new List<ulong>();
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<object>()
});
}
// 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<object>(),
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, Stopping);
if (result.MessageType == WebSocketMessageType.Close)
break;
var message = Encoding.UTF8.GetString(buffer, 0, result.Count).TrimEnd('\x1e');
// keep-alive pings ({"type":6}) are most of the traffic - ~49,000 lines a day
if (!message.StartsWith("{\"type\":6}"))
Console.WriteLine($"Player ({playerId}) sent {message}");
await HandleClientMessageAsync(playerId, socket, message);
}
}
catch (Exception ex)
{
Console.WriteLine($"[Error:{playerId}] {ex.Message}");
}
finally
{
pingCts.Cancel();
// Only tear down if THIS socket is still the player's registered one. When the game
// reconnects, the new connection registers itself first and this (old) handler ends
// afterwards - an unconditional TryRemove(playerId) here deleted the NEW socket, so
// a connected player read as offline: no pushes, friends saw them offline, and the
// voice lookup answered "nowhere".
bool wasCurrent = WebSockets.TryRemove(new KeyValuePair<ulong, WebSocket>(playerId, socket));
if (wasCurrent)
{
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}");
}
// The client may already be gone (no close handshake) - that's a normal hang-up,
// not an error, so it must not escape as an unhandled request exception.
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
try
{
using var closeCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Closing connection", closeCts.Token);
}
catch { }
}
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<ulong>());
var added = new List<ulong>();
var resend = new List<ulong>();
lock (set)
{
foreach (var id in ids)
{
if (id == playerId) continue;
if (set.Add(id)) added.Add(id);
// Already subscribed, but their presence changed in the last minute:
// send it again in case an older answer overtook the change (the
// client re-subscribes whenever its in-room player list changes).
else if (Campus_live_rewrite.api.dbs.HeartbeatDB.PresenceChangedWithin(id, TimeSpan.FromMinutes(1))) resend.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.Concat(resend))
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<ulong>();
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)
{
// Send-only close with a short timeout: a full CloseAsync waits for the old (often
// dead) client to answer, with no timeout, while the NEW connection waits here
// unregistered. The old handler's own receive loop ends on the close.
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
await oldSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Replaced by new connection", cts.Token);
}
catch { oldSocket.Abort(); }
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
};
}
}