Files
2026-10-16 23:33:13 -07:00

483 lines
14 KiB
C#

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<RecDBContext> DbFactory = null!;
public static IHubContext<NotifyHub> Context = null!;
public static List<HubClient> Clients = new List<HubClient>();
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, HubCallerContext> _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<long> ConnectedAccountIds()
{
lock (ClientsLock)
return Clients.Select(n => n.AccountId).Distinct().ToList();
}
public static List<HubClient> SnapshotClients()
{
lock (ClientsLock)
{
return Clients.ToList();
}
}
public static async Task SendMessage(long TargetId, Message msg, string? idFormOverride = null,
Action<Message>? 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<HubClient> 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<int> 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<HubClient> 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<string> 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<HubClient> 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<Presence?> 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<string> 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<HubClient> 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<long>();
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<HubClient> 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
});
}
}
}