mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
483 lines
14 KiB
C#
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
|
|
});
|
|
}
|
|
}
|
|
}
|