using Microsoft.AspNetCore.SignalR; using System.Globalization; using Microsoft.EntityFrameworkCore; using System.Text.Json; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Enums.Messages; using RadicadeServer.RecNet.Messages; using RadicadeServer.RecNet.Notifications; using RadicadeServer.RecNet.Rooms; using RadicadeServer.Utils; namespace RadicadeServer.API.Notifications { public static class Notify { public static IDbContextFactory DbFactory = null!; public static IHubContext Context = null!; public static List Clients = new List(); private static readonly System.Collections.Concurrent.ConcurrentDictionary _contexts = new(); public static void RegisterContext(HubCallerContext context) { _contexts[context.ConnectionId] = context; } public static void ForgetContext(string connectionId) { _contexts.TryRemove(connectionId, out _); } public static readonly object ClientsLock = new(); public static HubClient? GetClient(long accountId) { lock (ClientsLock) return Clients.FirstOrDefault(n => n.AccountId == accountId); } public static HubClient? GetConnectionClient(string connectionId) { lock (ClientsLock) return Clients.FirstOrDefault(n => n.ConnectionId == connectionId); } public static int ConnectedConnectionCount { get { lock (ClientsLock) return Clients.Count; } } public static List ConnectedAccountIds() { lock (ClientsLock) return Clients.Select(n => n.AccountId).Distinct().ToList(); } public static List SnapshotClients() { lock (ClientsLock) { return Clients.ToList(); } } public static async Task SendMessage(long TargetId, Message msg, string? idFormOverride = null, Action? prepareWithId = null) { if (DbFactory == null) { return; } await using var ctx = await DbFactory.CreateDbContextAsync(); msg.SentTime = DateTime.UtcNow; msg.ToPlayerId = TargetId; var originalId = msg.Id; await ctx.Database.CreateExecutionStrategy().ExecuteAsync(async () => { await using var attempt = await DbFactory.CreateDbContextAsync(); msg.Id = originalId; await using var transaction = prepareWithId != null && attempt.Database.IsRelational() ? await attempt.Database.BeginTransactionAsync() : null; await attempt.Messages.AddAsync(msg); await attempt.SaveChangesAsync(); if (prepareWithId != null) { prepareWithId(msg); await attempt.SaveChangesAsync(); } if (transaction != null) await transaction.CommitAsync(); }); List clients; lock (ClientsLock) clients = Notify.Clients.Where(n => n.AccountId == TargetId).ToList(); var notif = new Notification { Id = NotificationType.MessageReceived, Msg = msg }; if (clients.Count == 0) { return; } foreach (var client in clients) await SendToConnection(client.ConnectionId, notif, idFormOverride); } public static async Task SendUntilGoneAsync(long accountId, Notification message, TimeSpan window, TimeSpan interval, Notification? alternate = null) { if (window <= TimeSpan.Zero) window = TimeSpan.FromSeconds(8); if (interval <= TimeSpan.Zero) interval = TimeSpan.FromSeconds(1); var deadline = DateTime.UtcNow + window; var pushes = 0; while (true) { List clients; lock (ClientsLock) clients = Clients.Where(n => n.AccountId == accountId).ToList(); if (clients.Count == 0) { return pushes; } pushes++; var frame = alternate != null && pushes % 2 == 0 ? alternate : message; foreach (var client in clients) await SendToConnection(client.ConnectionId, frame); if (DateTime.UtcNow >= deadline) { return pushes; } await Task.Delay(interval); } } private static string PayloadProbe(Notification frame) { try { if (frame.Msg == null) return "null"; if (frame.Msg is string s) return s.Length == 0 ? "empty string" : "string"; return frame.Msg.GetType().Name switch { "JsonElement" => "object", _ => frame.Msg.GetType().Name }; } catch { return "unknown"; } } public static Notification EmptyObjectPayload(NotificationType id) => new() { Id = id, Msg = JsonDocument.Parse("{}").RootElement.Clone() }; public static Notification EmptyObjectPayload(string id) => new() { Id = id, Msg = JsonDocument.Parse("{}").RootElement.Clone() }; public static void SendReliably(long accountId, Notification message, TimeSpan window, TimeSpan interval, Notification? alternate = null) { _ = Task.Run(async () => { try { await SendUntilGoneAsync(accountId, message, window, interval, alternate); } catch (Exception) { } }); } public static int DisconnectAccount(long accountId, string reason) { List connectionIds; lock (ClientsLock) connectionIds = Clients.Where(n => n.AccountId == accountId).Select(n => n.ConnectionId).ToList(); var closed = 0; foreach (var connectionId in connectionIds) { if (!_contexts.TryRemove(connectionId, out var context)) continue; try { context.Abort(); closed++; } catch (Exception) { } } return closed; } public static async Task SendNotify(long AccountId, Notification Message) { List clients; lock (ClientsLock) clients = Notify.Clients.Where(n => n.AccountId == AccountId).ToList(); if (clients.Count == 0) { return; } foreach (var client in clients) await SendToConnection(client.ConnectionId, Message); } public static async Task SendSelfAccountUpdate(long accountId) { if (DbFactory == null) { return; } await using var ctx = await DbFactory.CreateDbContextAsync(); var profile = await ctx.Profiles.FirstOrDefaultAsync(x => x.accountId == accountId); if (profile == null) return; var plrData = await ctx.Players.FirstOrDefaultAsync(x => x.AccountId == accountId); if (plrData == null) return; await SendNotify(accountId, new Notification { Id = "SelfAccountUpdate", Msg = PlayerUtils.BuildSelfAccount(profile, plrData) }); } public static async Task BuildSelfPresenceAsync(RecDBContext ctx, long accountId) { var realPresence = await ctx.Presences.FirstOrDefaultAsync(x => x.playerId == accountId); if (realPresence == null) return null; RoomInstance? roomInstance = realPresence.roomInstance is > 0 ? await ctx.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == realPresence.roomInstance) : null; return await Presence.CreateAsync(ctx, realPresence, roomInstance); } public static async Task SendGlobalNotify(Notification Message) { if (Notify.Context == null) { return; } await Notify.Context.Clients.All.SendAsync("Notification", Message.ToJson()); } private static async Task SendToConnection(string connectionId, Notification Message, string? idFormOverride = null) { if (Notify.Context == null) { return; } await Notify.Context.Clients.Client(connectionId).SendAsync("Notification", idFormOverride == null ? Message.ToJson() : EnvelopeWithIdForm(Message, idFormOverride)); } public static string EnvelopeWithIdForm(Notification message, string idJson) => $"{{\"Id\":{idJson},\"Msg\":{message.Msg?.ToJson() ?? "null"}}}"; public static string IdFormJson(string? form, NotificationType type) { var number = Convert.ToInt64(type, CultureInfo.InvariantCulture) .ToString(CultureInfo.InvariantCulture); return (form ?? "string").Trim().ToLowerInvariant() switch { "number" => number, "name" => JsonSerializer.Serialize(type.ToString()), _ => JsonSerializer.Serialize(number), }; } public static async Task ReplayStoredCoachMessageAsync(long accountId, string? form) { if (DbFactory == null) return "no database configured"; await using var ctx = await DbFactory.CreateDbContextAsync(); var stored = await ctx.Messages .Where(m => m.ToPlayerId == accountId && m.Type == MessageType.CoachMessage) .OrderByDescending(m => m.Id) .FirstOrDefaultAsync(); if (stored == null) return $"account {accountId} has no stored coach message - send one from the admin panel first, then replay it."; List clients; lock (ClientsLock) clients = Notify.Clients.Where(n => n.AccountId == accountId).ToList(); if (clients.Count == 0) return $"account {accountId} has {connectedClause()} but no notify hub connection; nothing to push to. " + "Check the [NotifyHub] connect line in server-console.log."; var notif = new Notification { Id = NotificationType.MessageReceived, Msg = stored }; var idJson = IdFormJson(form, NotificationType.MessageReceived); foreach (var client in clients) await SendToConnection(client.ConnectionId, notif, idJson); var wire = EnvelopeWithIdForm(notif, idJson); return $"replayed stored coach message {stored.Id} to account {accountId} on {clients.Count} connection(s).\n" + $"Id token on the wire: {idJson}\n" + $"envelope: {wire}\n" + ExpectedClientReaction(idJson, NotificationType.MessageReceived); } private static string connectedClause() { var accounts = ConnectedAccountIds(); return accounts.Count == 0 ? "no notify hub connections at all" : $"{accounts.Count} account(s) connected ({string.Join(", ", accounts)})"; } private static string ExpectedClientReaction(string idJson, NotificationType type) { var number = Convert.ToInt64(type, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture); if (idJson == number) return "expected: the client logs a hub-frame parse failure and handles nothing - this is NOT a fix."; if (idJson.Trim('"') == type.ToString()) return "expected: the client parses the frame, finds no handler under that name, and silently ignores it."; return $"expected: the client handles it as {type} ({number}) - the message appears on the watch without a relog."; } public static string WireId(object? id) { try { return JsonSerializer.Serialize(id); } catch { return id?.ToString() ?? "null"; } } public static async Task SendAccountUpdate(long accountId) { if (DbFactory == null) return; await using var ctx = await DbFactory.CreateDbContextAsync(); var profile = await ctx.Profiles.FirstOrDefaultAsync(x => x.accountId == accountId); if (profile == null) return; await SendNotify(accountId, new Notification { Id = "AccountUpdate", Msg = profile }); await UpdateSubscriptions(accountId, Notifs.SubscriptionUpdateProfile, profile); await SendSelfAccountUpdate(accountId); } public static Task UpdateEveryone(long playerId) => UpdateSubscriptions(playerId); public static async Task UpdateSubscriptions(long playerId) { if (DbFactory == null) return; var subscribers = GetSubscriberConnections(playerId); if (subscribers.Count == 0) return; var factory = DbFactory; await Task.WhenAll(subscribers.Select(async subscriber => { try { await using var ctx = await factory.CreateDbContextAsync(); await SendStatusTo(ctx, playerId, subscriber); } catch (Exception) { } })); } public static async Task UpdateSubscriptionsForClient(string connectionId) { if (DbFactory == null) return; var client = GetConnectionClient(connectionId); if (client == null) return; long[] subscriptions; lock (client.SubscriptionsLock) subscriptions = client.HasSubscriptions ? client.Subscriptions.Distinct().ToArray() : Array.Empty(); if (subscriptions.Length == 0) return; await using var ctx = await DbFactory.CreateDbContextAsync(); foreach (var playerId in subscriptions) await SendStatusTo(ctx, playerId, client); } public static async Task UpdateSubscriptions(long playerId, object id, object message) { var subscribers = GetSubscriberConnections(playerId); var wireId = id is Notifs.SubscriptionUpdateProfile ? "AccountUpdate" : id; var notification = new Notification { Id = wireId, Msg = message }; if (notification.Id is "AccountUpdate") await UpdateSubscriptions(playerId); foreach (var subscriber in subscribers) await SendToConnection(subscriber.ConnectionId, notification); } public static async Task SendToSubscribers(long playerId, Notification message) { foreach (var subscriber in GetSubscriberConnections(playerId)) await SendToConnection(subscriber.ConnectionId, message); } private static List GetSubscriberConnections(long playerId) { return SnapshotClients().Where(client => { lock (client.SubscriptionsLock) return client.HasSubscriptions && client.Subscriptions.Contains(playerId); }).ToList(); } private static async Task SendStatusTo(RecDBContext ctx, long fromPlayerId, HubClient recipient) { var realPresence = await ctx.Presences.FirstOrDefaultAsync(x => x.playerId == fromPlayerId); if (realPresence == null) return; RoomInstance? roomInstance = realPresence.roomInstance is > 0 ? await ctx.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == realPresence.roomInstance) : null; roomInstance = await RoomUtils.ForViewerAsync(roomInstance, recipient.AccountId); if (roomInstance != null) { roomInstance = roomInstance.Copy(); roomInstance.photonRegion = string.Empty; roomInstance.photonRoomId = string.Empty; roomInstance.dataBlob = string.Empty; roomInstance.roomCode = string.Empty; } var playerPresence = await Presence.CreateAsync(ctx, realPresence, roomInstance); playerPresence.ForViewer(recipient.AccountId); await SendToConnection(recipient.ConnectionId, new Notification { Id = "PresenceUpdate", Msg = playerPresence }); } } }