mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
|
||||
namespace RadicadeServer.RecNet.Notifications
|
||||
{
|
||||
public class Announcement
|
||||
{
|
||||
public long AnnouncementId { get; set; }
|
||||
public int AnnouncementStatus { get; set; }
|
||||
public int AnnouncementType { get; set; }
|
||||
public string Title { get; set; } = null!;
|
||||
public string Body { get; set; } = null!;
|
||||
public string ImageName { get; set; } = null!;
|
||||
public bool IsMostRecent { get; set; }
|
||||
public string LinkName { get; set; } = null!;
|
||||
public int LinkType { get; set; }
|
||||
public string LinkUri { get; set; } = null!;
|
||||
public Platforms Platform { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace RadicadeServer.RecNet.Notifications
|
||||
{
|
||||
public static class AnnouncementBroadcast
|
||||
{
|
||||
|
||||
public const string UpdateNotificationId = "AnnouncementUpdate";
|
||||
|
||||
public static Notification Update(Announcement announcement)
|
||||
=> new() { Id = UpdateNotificationId, Msg = announcement };
|
||||
|
||||
|
||||
|
||||
public const string DeleteNotificationId = "AnnouncementDelete";
|
||||
|
||||
public static Notification Delete(Announcement announcement)
|
||||
=> new() { Id = DeleteNotificationId, Msg = announcement };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace RadicadeServer.RecNet.Notifications
|
||||
{
|
||||
public class HubClient
|
||||
{
|
||||
public long AccountId { get; set; }
|
||||
public string ConnectionId { get; set; } = string.Empty;
|
||||
public List<long> Subscriptions { get; set; } = new();
|
||||
public object SubscriptionsLock { get; } = new();
|
||||
public bool HasSubscriptions { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace RadicadeServer.RecNet.Notifications
|
||||
{
|
||||
public class Notification
|
||||
{
|
||||
private object id = null!;
|
||||
|
||||
public object Id
|
||||
{
|
||||
get => id is System.Enum value
|
||||
? System.Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture)
|
||||
.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
: id;
|
||||
set => id = value;
|
||||
}
|
||||
|
||||
public object? Msg { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Accounts;
|
||||
using RadicadeServer.RecNet.Notifications;
|
||||
using RadicadeServer.RecNet.Enums.Messages;
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.API.Notifications
|
||||
{
|
||||
public class NotifyHub : Hub
|
||||
{
|
||||
private static readonly string[] TokenCarriers = { "access_token", "accessToken", "token", "id_token" };
|
||||
|
||||
private RecDBContext db;
|
||||
public NotifyHub(IHubContext<NotifyHub> hubContext, RecDBContext db) {
|
||||
Notify.Context = hubContext;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private string? RawToken()
|
||||
{
|
||||
var httpContext = Context.GetHttpContext();
|
||||
if (httpContext == null) return null;
|
||||
|
||||
foreach (var name in TokenCarriers)
|
||||
{
|
||||
var fromQuery = httpContext.Request.Query[name].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(fromQuery)) return fromQuery;
|
||||
}
|
||||
|
||||
var header = httpContext.Request.Headers["Authorization"].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(header)) return header;
|
||||
|
||||
foreach (var name in TokenCarriers)
|
||||
{
|
||||
if (httpContext.Request.Cookies.TryGetValue(name, out var cookie) && !string.IsNullOrWhiteSpace(cookie))
|
||||
return cookie;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private long? GetAccountId()
|
||||
{
|
||||
var authenticatedAccount = Context.User?.GetUserId();
|
||||
if (authenticatedAccount != null) return authenticatedAccount;
|
||||
|
||||
var raw = RawToken();
|
||||
return string.IsNullOrWhiteSpace(raw) ? null : raw.GetUserId();
|
||||
}
|
||||
|
||||
private static JsonElement RawJson(string json) => JsonDocument.Parse(json).RootElement.Clone();
|
||||
|
||||
private async Task SendNotificationToCaller(string id, object msg)
|
||||
{
|
||||
var notification = new Notification { Id = id, Msg = msg };
|
||||
await Clients.Caller.SendAsync("Notification", notification.ToJson());
|
||||
}
|
||||
|
||||
private static List<long> ReadPlayerIds(object? update)
|
||||
{
|
||||
var ids = new List<long>();
|
||||
|
||||
void Add(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out var single))
|
||||
{
|
||||
ids.Add(single);
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.ValueKind == JsonValueKind.String && long.TryParse(element.GetString(), out single))
|
||||
{
|
||||
ids.Add(single);
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Array) return;
|
||||
|
||||
foreach (var entry in element.EnumerateArray())
|
||||
{
|
||||
if (entry.ValueKind == JsonValueKind.Number && entry.TryGetInt64(out var value))
|
||||
ids.Add(value);
|
||||
else if (entry.ValueKind == JsonValueKind.String && long.TryParse(entry.GetString(), out value))
|
||||
ids.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (update is SubscriptionUpdate subscription && subscription.PlayerIds != null)
|
||||
{
|
||||
ids.AddRange(subscription.PlayerIds);
|
||||
return ids;
|
||||
}
|
||||
|
||||
if (update is not JsonElement json) return ids;
|
||||
|
||||
if (json.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
Add(json);
|
||||
return ids;
|
||||
}
|
||||
|
||||
if (json.ValueKind != JsonValueKind.Object) return ids;
|
||||
|
||||
foreach (var property in json.EnumerateObject())
|
||||
{
|
||||
if (!property.Name.Equals("PlayerIds", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
Add(property.Value);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
[HubMethodName("SubscribeToPlayers")]
|
||||
public Task Subscribe(object? update) => ApplySubscriptions(update);
|
||||
|
||||
[HubMethodName("playerSubscriptions/v1/update")]
|
||||
public Task SubscribeToPlayersV1(object? update) => ApplySubscriptions(update);
|
||||
|
||||
private async Task ApplySubscriptions(object? update)
|
||||
{
|
||||
long? account = GetAccountId();
|
||||
if (account == null) return;
|
||||
|
||||
var ids = ReadPlayerIds(update);
|
||||
|
||||
var client = Notify.GetConnectionClient(Context.ConnectionId);
|
||||
if (client != null)
|
||||
{
|
||||
lock (client.SubscriptionsLock)
|
||||
{
|
||||
client.Subscriptions = ids.Where(id => id > 0).Distinct().ToList();
|
||||
client.HasSubscriptions = true;
|
||||
}
|
||||
await Notify.UpdateSubscriptionsForClient(Context.ConnectionId);
|
||||
}
|
||||
}
|
||||
|
||||
[HubMethodName("SubscribeTo")]
|
||||
public Task SubscribeToChannels(object? update) => ApplyChannelSubscriptions(update, subscribing: true);
|
||||
|
||||
[HubMethodName("UnsubscribeFrom")]
|
||||
public Task UnsubscribeFromChannels(object? update) => ApplyChannelSubscriptions(update, subscribing: false);
|
||||
|
||||
private Task ApplyChannelSubscriptions(object? update, bool subscribing)
|
||||
{
|
||||
var channels = ReadChannels(update);
|
||||
if (channels.Count == 0) return Task.CompletedTask;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static List<string> ReadChannels(object? update)
|
||||
{
|
||||
var channels = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
if (update is not JsonElement json || json.ValueKind != JsonValueKind.Object) return channels;
|
||||
|
||||
foreach (var property in json.EnumerateObject())
|
||||
{
|
||||
if (!property.Name.Equals("Channels", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (property.Value.ValueKind != JsonValueKind.Array) continue;
|
||||
|
||||
foreach (var entry in property.Value.EnumerateArray())
|
||||
{
|
||||
var channel = entry.ValueKind == JsonValueKind.String ? entry.GetString() : null;
|
||||
if (!string.IsNullOrWhiteSpace(channel)) channels.Add(channel!);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
[HubMethodName("heartbeat2")]
|
||||
public Task<object?> Heartbeat() => AnswerHeartbeat();
|
||||
|
||||
[HubMethodName("heartbeat")]
|
||||
public Task<object?> HeartbeatUnversioned() => AnswerHeartbeat();
|
||||
|
||||
private async Task<object?> AnswerHeartbeat()
|
||||
{
|
||||
var account = GetAccountId();
|
||||
if (account == null) return null;
|
||||
|
||||
var presence = await Notify.BuildSelfPresenceAsync(db, account.Value);
|
||||
if (presence == null) return null;
|
||||
|
||||
await SendNotificationToCaller("PresenceUpdate", presence);
|
||||
|
||||
return RawJson($"{{\"Id\":{account.Value},\"Msg\":{presence.ToJson()}}}");
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
var httpContext = Context.GetHttpContext();
|
||||
long? account = GetAccountId();
|
||||
var tokenCarrier = string.IsNullOrWhiteSpace(RawToken()) ? "none" : "present";
|
||||
|
||||
|
||||
if (account != null)
|
||||
{
|
||||
Notify.RegisterContext(Context);
|
||||
|
||||
lock (Notify.ClientsLock) Notify.Clients.Add(new HubClient
|
||||
{
|
||||
AccountId = account.Value,
|
||||
ConnectionId = Context.ConnectionId
|
||||
});
|
||||
|
||||
var activePresence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == account.Value);
|
||||
if (activePresence != null && !activePresence.isOnline && activePresence.roomInstance.HasValue)
|
||||
{
|
||||
activePresence.isOnline = true;
|
||||
await db.SaveChangesAsync();
|
||||
await Notify.UpdateSubscriptions(account.Value);
|
||||
}
|
||||
|
||||
try { await Notify.SendSelfAccountUpdate(account.Value); }
|
||||
catch (Exception) { }
|
||||
|
||||
try
|
||||
{
|
||||
var presence = await Notify.BuildSelfPresenceAsync(db, account.Value);
|
||||
if (presence != null) await SendNotificationToCaller("PresenceUpdate", presence);
|
||||
}
|
||||
catch (Exception) { }
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
var ban = await db.ResolveBanAsync(account.Value);
|
||||
if (ban != null)
|
||||
{
|
||||
var notification = new Notification { Id = NotificationType.ModerationKick, Msg = await PlayerUtils.BuildBanBlockDetailsAsync(db, ban, account.Value) };
|
||||
await Clients.Caller.SendAsync("Notification", notification.ToJson());
|
||||
}
|
||||
} catch (Exception)
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var pendingWarning = await db.GetPendingWarningAsync(account.Value);
|
||||
if (pendingWarning != null)
|
||||
{
|
||||
await PlayerUtils.DeliverWarningAsync(account.Value,
|
||||
PlayerUtils.BuildWarningBlockDetails(pendingWarning, (int)PlayerUtils.WarningScreenHold.TotalSeconds));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
var userId = GetAccountId();
|
||||
|
||||
lock (Notify.ClientsLock) Notify.Clients.RemoveAll(x => x.ConnectionId == Context.ConnectionId);
|
||||
Notify.ForgetContext(Context.ConnectionId);
|
||||
|
||||
if (userId != null)
|
||||
{
|
||||
bool stillConnected;
|
||||
lock (Notify.ClientsLock)
|
||||
stillConnected = Notify.Clients.Any(x => x.AccountId == userId.Value);
|
||||
|
||||
if (!stillConnected)
|
||||
{
|
||||
var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == userId.Value);
|
||||
lock (Notify.ClientsLock)
|
||||
stillConnected = Notify.Clients.Any(x => x.AccountId == userId.Value);
|
||||
if (presence != null && !stillConnected)
|
||||
{
|
||||
presence.isOnline = false;
|
||||
await db.SaveChangesAsync();
|
||||
await Notify.UpdateSubscriptions(userId.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace RadicadeServer.API.Notifications
|
||||
{
|
||||
public sealed class NotifyHubInvocationFilter : IHubFilter
|
||||
{
|
||||
public async ValueTask<object?> InvokeMethodAsync(
|
||||
HubInvocationContext invocationContext,
|
||||
Func<HubInvocationContext, ValueTask<object?>> next)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await next(invocationContext);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace RadicadeServer.API.Notifications
|
||||
{
|
||||
public static class NotifyHubRoutes
|
||||
{
|
||||
public static readonly string[] Paths =
|
||||
{
|
||||
"/notify/hub/v1",
|
||||
"/notify/hub",
|
||||
"/notifyhub/v1",
|
||||
"/hub/v1",
|
||||
"/hub",
|
||||
};
|
||||
|
||||
public static bool IsHubPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return false;
|
||||
|
||||
foreach (var route in Paths)
|
||||
{
|
||||
if (path.Equals(route, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (path.StartsWith(route + "/", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace RadicadeServer.API.Notifications;
|
||||
|
||||
[ApiController]
|
||||
[Route("platformnotifications")]
|
||||
public sealed class PlatformNotificationsAPI : ControllerBase
|
||||
{
|
||||
[HttpGet("preferences"), Authorize]
|
||||
public IActionResult GetPreferences() => Ok(new { email = true, push = true, notifications = true });
|
||||
|
||||
[HttpGet("accounts/{accountId:long}/receives/{notificationType}"), Authorize]
|
||||
public IActionResult Receives(long accountId, string notificationType) => Ok(true);
|
||||
|
||||
[HttpGet("crm/me/config"), Authorize]
|
||||
public IActionResult GetCrmConfig() => Ok(new
|
||||
{
|
||||
Results = Array.Empty<object>(),
|
||||
TotalResults = 0L
|
||||
});
|
||||
|
||||
[HttpGet("config/categories"), Authorize]
|
||||
public IActionResult GetCategories() => Ok(new
|
||||
{
|
||||
Results = Array.Empty<object>(),
|
||||
TotalResults = 0L
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.API.Storefront;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.RecNet.Enums.Store;
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.API.Notifications
|
||||
{
|
||||
public static class RewardDiag
|
||||
{
|
||||
private const int DefaultLogLines = 120;
|
||||
|
||||
public static async Task<string> DescribeAsync(RecDBContext db, long accountId, int logLines)
|
||||
{
|
||||
var lines = new System.Text.StringBuilder();
|
||||
|
||||
lines.AppendLine($"build {BuildInfo.Tag}; assembly published {BuildInfo.AssemblyBuiltUtc:u}; running since {BuildInfo.StartedUtc:u}");
|
||||
lines.AppendLine($"account {accountId}");
|
||||
|
||||
var progression = await db.Progression.AsNoTracking().FirstOrDefaultAsync(x => x.PlayerId == accountId);
|
||||
var json = progression?.GetProgressionJson();
|
||||
var rewards = json?.PendingGameRewards ?? new List<RecNet.Storefront.GameReward>();
|
||||
|
||||
var day = json == null ? null : RewardDay.For(json, DateTime.UtcNow);
|
||||
if (day != null)
|
||||
lines.AppendLine($"today: dayKey={day.Day} firstActivityPaid={day.FirstActivityPaid} " +
|
||||
$"dailyChallengePaid={day.DailyChallengePaid} activityBoxes={day.ActivityBoxes}/{RewardDay.ActivityBoxesPerDay}");
|
||||
|
||||
lines.AppendLine($"reward selections kept: {rewards.Count} ({rewards.Count(r => !r.Consumed)} outstanding)");
|
||||
if (rewards.Count == 0)
|
||||
lines.AppendLine(" (none - no reward has been requested for this account since the ledger was last reset)");
|
||||
|
||||
foreach (var reward in rewards.OrderByDescending(r => r.CreatedAt))
|
||||
{
|
||||
var state = reward.Consumed
|
||||
? $"collected box={reward.ConsumedGiftDropId} at {reward.ConsumedAt:u}"
|
||||
: "OUTSTANDING";
|
||||
lines.AppendLine($" selectionId={reward.RewardSelectionId} rewardType={reward.RewardType} " +
|
||||
$"context={RewardTrace.Context(reward.GiftContext)} created={reward.CreatedAt:u} {state}");
|
||||
lines.AppendLine($" message=\"{reward.Message}\"");
|
||||
lines.AppendLine($" drop1={RewardTrace.Box(reward.GiftDrop1)}");
|
||||
lines.AppendLine($" drop2={RewardTrace.Box(reward.GiftDrop2)}");
|
||||
lines.AppendLine($" drop3={RewardTrace.Box(reward.GiftDrop3)}");
|
||||
}
|
||||
|
||||
var boxes = await StorefrontShared.GetPendingGiftsAsync(db, accountId);
|
||||
lines.AppendLine($"pending gift boxes: {boxes.Count}");
|
||||
foreach (var box in boxes)
|
||||
lines.AppendLine($" {RewardTrace.Box(box)} room={RroRewardPools.RoomForContext(box.Context) ?? "<none>"}");
|
||||
|
||||
lines.AppendLine($"game-rewards.log tail (last {logLines} lines):");
|
||||
try
|
||||
{
|
||||
var path = System.IO.Path.Join(DBConfig.PersistentLogs, "game-rewards.log");
|
||||
if (!System.IO.File.Exists(path))
|
||||
lines.AppendLine(" (no reward log has been written yet - the reward routes have not been hit)");
|
||||
else
|
||||
foreach (var line in System.IO.File.ReadLines(path).TakeLast(Math.Clamp(logLines, 1, 2000)))
|
||||
lines.AppendLine(" " + line);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lines.AppendLine($" (the reward log could not be read: {ex.Message})");
|
||||
}
|
||||
|
||||
return lines.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Enums.Messages;
|
||||
using RadicadeServer.RecNet.Notifications;
|
||||
|
||||
namespace RadicadeServer.API.Notifications
|
||||
{
|
||||
public static class RewardPushDiag
|
||||
{
|
||||
public const string RegisteredName = "RewardSelectionReceived";
|
||||
|
||||
public static string EnumNumber => Convert.ToInt64(NotificationType.GiftPackageRewardSelectionReceived,
|
||||
System.Globalization.CultureInfo.InvariantCulture)
|
||||
.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
public static async Task<string> ReplayPendingRewardSelectionAsync(RecDBContext db, long accountId, string? idForm)
|
||||
{
|
||||
var id = Resolve(idForm);
|
||||
|
||||
var progression = await db.Progression.AsNoTracking().FirstOrDefaultAsync(x => x.PlayerId == accountId);
|
||||
var pending = progression?.GetProgressionJson()?.PendingGameRewards;
|
||||
var reward = pending?.Where(r => !r.Consumed).OrderByDescending(r => r.CreatedAt).FirstOrDefault();
|
||||
|
||||
if (reward == null)
|
||||
return $"account {accountId} has no pending reward selection - finish a game or a daily challenge first.\n";
|
||||
|
||||
var connections = Notify.SnapshotClients().Count(c => c.AccountId == accountId);
|
||||
var notification = new Notification { Id = id, Msg = reward };
|
||||
await Notify.SendNotify(accountId, notification);
|
||||
|
||||
var idJson = JsonSerializer.Serialize(id);
|
||||
var lines = new System.Text.StringBuilder();
|
||||
lines.AppendLine($"account {accountId}: pushed reward selectionId={reward.RewardSelectionId} ({reward.RewardType}, \"{reward.Message}\") on {connections} connection(s).");
|
||||
lines.AppendLine($"Id token on the wire: {idJson}" + (id == RegisteredName ? " (the key the client registered a handler under)" : string.Empty));
|
||||
lines.AppendLine($"expected: {(id == EnumNumber ? "the client parses the frame, finds no handler for a bare number unless its own table has one, and handles nothing." : "the client's dispatcher finds a handler under this exact key and the selection screen opens - no relog.")}");
|
||||
lines.AppendLine($"envelope: {Notify.EnvelopeWithIdForm(notification, idJson)}");
|
||||
lines.AppendLine($"pending for this account now: {pending!.Count(r => !r.Consumed)} selection(s).");
|
||||
return lines.ToString();
|
||||
}
|
||||
|
||||
private static string Resolve(string? idForm) => (idForm ?? "name").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"name" => RegisteredName,
|
||||
"number" => EnumNumber,
|
||||
"" => RegisteredName,
|
||||
_ => idForm!.Trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace RadicadeServer.RecNet.Notifications
|
||||
{
|
||||
public class SubscriptionUpdate
|
||||
{
|
||||
public List<long> PlayerIds { get; set; } = new();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user