Files
DorkNet/DorkNet.Server/Hubs/NotifyHub.cs
T
Alexa e33145307f fix: notification pushes now match what the 2023 client reads
Most pushes were still shaped for the 2020 watch, so the 2023 client
parsed them as empty or dropped them without any error.

- RelationshipChanged carries the relationship row, to both players, on
  friend request / accept / remove / add and on favorite / mute / ignore.
  Friend requests and accepts also land in the notifications inbox.
- ModerationKick uses the client's flat moderation block, with the
  reason in VoteKickReason (the only field the dialog shows).
- Bans, account deletion, password resets and second-session takeover
  are a Logout with a numeric reason instead of a kick to the dorm; the
  old string reason failed the client's parse.
- SubscribeToPlayers is honoured: each connection joins a presence group
  per subscribed player, and presence (only presence) reaches subscribers
  as well as friends. The hub allows 180 s of client silence, since the
  client reconnects at most 3 times per login.
- Admin broadcast sends its text as a coach message and can start the
  client's own maintenance countdown; ServerMaintenance only reads
  StartsInMinutes.
- ConsumableMapping, StorefrontBalance, gift, player event, level-up,
  RoomUpdate, profile-image and PlayerLeftChat pushes carry the client's
  DTOs. "Join rejected" is the client's RequestGameInvite message.

Contract in docs/recroom-2023-notifications.md; push frames are checked
by NotificationContractTests through a capturing hub context.
2026-10-24 19:50:05 +03:00

272 lines
12 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
using System.Security.Claims;
using System.Text.Json;
namespace DorkNet.Server.Hubs;
/// <summary>
/// notify.rec.net/hub/v1 — SignalR Core hub used by the client's
/// BestHTTP.SignalRCore.HubConnection (RecNet.SignalRHubConnection wraps it).
///
/// Each connection authenticates via the same JWT used for the rest of
/// the API; we extract the player id from the bearer claim and add the
/// connection to a per-player SignalR group so the rest of the app can
/// push to "player N" by group name without tracking connection ids
/// directly.
///
/// **Per-replica vs cross-replica state**: SignalR's group fan-out is
/// driven by its registered backplane — when Redis is configured in
/// <c>Program.cs</c> via <c>AddStackExchangeRedis</c>, a notification
/// published to <c>player:N</c> from any replica reaches every
/// replica's matching connections. Connection-tracking for
/// "is player N online?" queries lives in <see cref="OnlinePresenceService"/>
/// (also Redis-backed) so admin tools see the cluster-wide truth.
/// Pre-PR-2 used a local <c>static ConcurrentDictionary</c> which only
/// reflected this replica's connections.
///
/// Hub methods the 2023-03-21 client sends (fire-and-forget, RecNet.Runtime
/// OMHPBIEHOHN):
/// "SubscribeToPlayers" - {PlayerIds}: players to get presence for
/// "SubscribeTo" - {Channels}: always empty in this build
/// "UnsubscribeFrom" - {Channels}: always empty in this build
/// There is no UnsubscribeFromPlayers; SubscribeToPlayers always carries the
/// complete set. All accept JsonElement and tolerate casing differences.
///
/// The client reconnects at most 3 times per login (its attempt counter is
/// never reset), so the server must not drop healthy connections.
/// </summary>
[Authorize]
public class NotifyHub(
OnlinePresenceService presence,
PlayerPresenceService playerRooms,
NotificationService notifications,
PresenceBroadcastService presenceFanout,
ILogger<NotifyHub> logger) : Hub
{
/// <summary>SignalR group name for a given player id. Use this
/// from <see cref="Services.NotificationService"/> to push.</summary>
public static string GroupForPlayer(long playerId) => $"player:{playerId}";
public override async Task OnConnectedAsync()
{
var pid = TryGetPlayerId();
if (pid is long id)
{
// Enforce single-session-per-account. When a SECOND device
// logs in with the same credentials, both the original
// session and the new one would otherwise share the
// <c>player:N</c> group. We can't differentiate by LoginLock
// because the watch derives that token from cached login
// state (device_id-tied) — two clients restored from the
// same install hand back the same lock GUID.
//
// Instead: at the moment the SECOND session's WebSocket
// arrives, take a snapshot of the EXISTING connection ids
// (which still belong to the original session — we haven't
// joined the group yet) and send those ids a Logout with
// Reason SessionTakeover. The client shows its own "signed in
// elsewhere" message and returns to the boot scene. (A
// ModerationKick only sent the old session to its dorm, still
// signed in.)
//
// Targeting specific connection ids (not the group) means
// the new connection — about to join the group on the next
// line — never receives it, even with Redis backplane
// routing across replicas.
var existing = presence.GetConnections(id);
if (existing.Count > 0)
{
logger.LogWarning(
"[hub] second-session connect for player={PlayerId} (conn={NewConn}); logging out {OldCount} existing connection(s)",
id, Context.ConnectionId, existing.Count);
try
{
await notifications.LogoutConnectionsAsync(existing);
}
catch (Exception ex)
{
logger.LogWarning(ex, "[hub] failed to kick stale connections for player={PlayerId}", id);
}
}
await Groups.AddToGroupAsync(Context.ConnectionId, GroupForPlayer(id));
var cameOnline = await presence.AddConnectionAsync(id, Context.ConnectionId);
logger.LogInformation(
"[hub] connected player={PlayerId} conn={ConnectionId} group={Group} cameOnline={CameOnline}",
id, Context.ConnectionId, GroupForPlayer(id), cameOnline);
// Tell friends they're back. OnDisconnectedAsync pushes
// PlayerWentOffline, but nothing used to push the opposite
// transition, so the pair was asymmetric: a SignalR reconnect
// (network blip, replica restart) greyed the player out on every
// friend's list for the rest of the session even though they were
// still playing.
//
// isOnline is sent explicitly now, so this no longer needs a
// known room: a player who connects before matchmaking, or who is
// sitting in the offline room, is online with roomInstance null.
if (cameOnline)
await presenceFanout.PublishCurrentAsync(id);
}
else
{
logger.LogWarning(
"[hub] connected without player id conn={ConnectionId} user={User}",
Context.ConnectionId, Context.User?.Identity?.Name);
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var pid = TryGetPlayerId();
if (pid is long id)
{
var wentOffline = await presence.RemoveConnectionAsync(id, Context.ConnectionId);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupForPlayer(id));
if (wentOffline)
{
// The socket can drop under a live session (the server or a
// proxy closes it; the client logs "RecNet HubConnection error:
// ... TCP Stream closed unexpectedly by the remote server" and
// reconnects by itself). While the player is still
// heartbeating, wait one activity window before calling them
// offline; a real exit either logs out (immediate offline push)
// or stops heartbeating and is caught by the deferred check.
if (playerRooms.IsRecentlyActive(id))
presenceFanout.ScheduleOfflineCheck(id);
else
await presenceFanout.PublishOfflineAsync(id);
}
logger.LogInformation(
"[hub] disconnected player={PlayerId} conn={ConnectionId} wentOffline={WentOffline} error={Error}",
id, Context.ConnectionId, wentOffline, exception?.Message);
}
else
{
logger.LogInformation(
"[hub] disconnected unknown-player conn={ConnectionId} error={Error}",
Context.ConnectionId, exception?.Message);
}
await base.OnDisconnectedAsync(exception);
}
/// <summary>SignalR group a connection joins to receive one player's
/// presence. Only presence is sent here — a subscriber must never receive
/// the subject's own pushes (DMs, gifts, …).</summary>
public static string PresenceGroupForPlayer(long playerId) => $"presence:{playerId}";
private const string SubscriptionsKey = "presence-subscriptions";
/// <summary><c>SubscribeToPlayers(PlayerSubscriptionListDTO {PlayerIds:
/// List&lt;int&gt;})</c> (RecNet.Runtime OMHPBIEHOHN.txt:3430): the full set
/// of players this client wants presence for — its relationships, the
/// players around it, and itself. Sent on every (re)connect and whenever
/// the set changes, always as the complete set, so it REPLACES the
/// connection's subscriptions. Presence for those players is then pushed
/// to <see cref="PresenceGroupForPlayer"/> by
/// <see cref="PresenceBroadcastService"/>, so non-friends the client is
/// looking at (people in the room, an open profile) stay current too.
///
/// The caller's own id is skipped: a presence frame about the local player
/// is applied as a correction and moves them. An unreadable payload
/// leaves the current subscriptions alone rather than clearing them.
/// Groups live per connection on its own replica, are routed cluster-wide
/// by the Redis backplane, and are dropped by SignalR on disconnect.</summary>
public async Task SubscribeToPlayers(JsonElement payload)
{
var me = TryGetPlayerId();
if (!TryReadPlayerIds(payload, out var requested))
{
logger.LogWarning(
"[hub] SubscribeToPlayers unreadable payload player={PlayerId} conn={ConnectionId} payload={Payload}",
me, Context.ConnectionId, RawPayload(payload));
return;
}
if (me is long self) requested.Remove(self);
var current = Context.Items.TryGetValue(SubscriptionsKey, out var existing) && existing is HashSet<long> set
? set
: new HashSet<long>();
var added = requested.Except(current).ToList();
var removed = current.Except(requested).ToList();
foreach (var id in added)
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id));
foreach (var id in removed)
await Groups.RemoveFromGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id));
Context.Items[SubscriptionsKey] = requested;
logger.LogInformation(
"[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} subscribed={Count} added={Added} removed={Removed}",
me, Context.ConnectionId, requested.Count, added.Count, removed.Count);
}
/// <summary><c>SubscribeTo({Channels: List&lt;string&gt;})</c>. The
/// 2023-03-21 client composes it on every connect but its channel set is
/// always empty, so there is nothing to subscribe to; logged only.</summary>
public Task SubscribeTo(JsonElement payload)
{
logger.LogInformation(
"[hub] SubscribeTo player={PlayerId} conn={ConnectionId} payload={Payload}",
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
return Task.CompletedTask;
}
/// <summary>Counterpart of <see cref="SubscribeTo"/>; logged only.</summary>
public Task UnsubscribeFrom(JsonElement payload)
{
logger.LogInformation(
"[hub] UnsubscribeFrom player={PlayerId} conn={ConnectionId} payload={Payload}",
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
return Task.CompletedTask;
}
/// <summary>Reads <c>{PlayerIds:[…]}</c> (any key casing), or a bare
/// array, as player ids; numbers or numeric strings.</summary>
public static bool TryReadPlayerIds(JsonElement payload, out HashSet<long> ids)
{
ids = new HashSet<long>();
var list = payload;
if (payload.ValueKind == JsonValueKind.Object)
{
var found = false;
foreach (var prop in payload.EnumerateObject())
{
if (!string.Equals(prop.Name, "PlayerIds", StringComparison.OrdinalIgnoreCase)) continue;
list = prop.Value;
found = true;
break;
}
if (!found) return false;
}
if (list.ValueKind != JsonValueKind.Array) return false;
foreach (var item in list.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var n) && n > 0) ids.Add(n);
else if (item.ValueKind == JsonValueKind.String && long.TryParse(item.GetString(), out var p) && p > 0) ids.Add(p);
}
return true;
}
private long? TryGetPlayerId()
{
var raw = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier);
return long.TryParse(raw, out var id) ? id : null;
}
private static string RawPayload(JsonElement payload)
{
try
{
return payload.ValueKind == JsonValueKind.Undefined ? "<undefined>" : payload.GetRawText();
}
catch
{
return "<unreadable>";
}
}
}