using DorkNet.Models.Notification;
using DorkNet.Server.Controllers.Match;
using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Hubs;
using Microsoft.AspNetCore.SignalR;
using System.Text.Json;
namespace DorkNet.Server.Services;
///
/// Pushes s to specific players over the
/// SignalR . Each connected player belongs to a
/// per-player group named player:<id>; sending to that group
/// reaches every active connection for that player without us tracking
/// individual connection ids.
///
/// **Wire contract** (verified against
/// Cpp2IL_ISIL/.../RecNet/Notifications.txt:1979-1994):
/// - Hub method name: "Notification" (singular). The client
/// calls connection.On("Notification", Action<string>).
/// - Payload: a SINGLE string argument containing JSON of
/// the form {"Id":int, "Msg":object}. The client's handler
/// then parses that string into a Dictionary<string, object>
/// and dispatches by Id to per-handler callbacks registered via
/// RegisterHandler(PushNotificationId, NotificationHandler).
///
/// Earlier versions sent SendAsync("ReceiveNotification", obj)
/// — wrong method name AND wrong arg type (object instead of string).
/// All push notifications were silently dropped client-side; admin
/// kicks / broadcasts / friend-request alerts never reached the
/// in-game client. Keep the method name + string serialisation
/// in sync with the decompiled client when extending.
///
/// Higher-level helpers (FriendRequestReceived, FriendRequestAccepted,
/// PlayerEnteredRoom, etc.) wrap NotifyAsync so callers don't have to
/// pick the right by hand.
///
public class NotificationService(
IHubContext hub,
OnlinePresenceService onlinePresence,
ILogger logger)
{
private const string HubMethod = "Notification";
private static readonly JsonSerializerOptions Json = new()
{
// Client uses LitJson which reads PascalCase keys verbatim;
// no camelCase / snake_case conversion in the wire payload.
PropertyNamingPolicy = null,
};
/// The watch's Notifications.OnNotificationReceived
/// reads Id as a string and dispatches against
/// handlers : Dictionary<string, NotificationHandler>
/// (verified at Cpp2IL_ISIL/.../RecNet/Notifications.txt:2097-2222).
/// Wire name per id is whatever the registering class actually
/// stored in the handlers dict — verified live by tracing every
/// Notifications.RegisterHandler call at boot via
/// tools/SaveDebugMod's [notify-register] log lines.
/// The handlers fall into three buckets:
///
/// - Registered ONLY under the bare integer string (no
/// enum-name companion): MessageReceived (2),
/// MessageDeleted (3), ModerationKick (22),
/// ModerationKickAttemptFailed (23),
/// ModerationRoomBan (24), and the entire
/// PlayerEvent* family (80-85). Sending the enum
/// name (e.g. "MessageReceived") makes lookup miss
/// and the push drops silently — this is exactly what
/// broke DMs / game-invite delivery: the server logged
/// [notify] sending id=MessageReceived ...
/// connections=1 AND the watch's topmost
/// OnNotificationReceived hook saw the payload, but
/// Messages.OnMessageReceived never fired because
/// handlers["MessageReceived"] didn't exist —
/// handlers["2"] did.
/// - Registered under a CUSTOM short name (no integer
/// companion): SubscriptionUpdate* →
/// "PresenceUpdate" / "RoomInstanceUpdate" /
/// "AccountUpdate", and ChatMessageReceived
/// (90) which is registered only as
/// "ChatMessageReceived", NOT "90".
/// - Registered through the enum overload, which the IL2CPP
/// client lowers to the numeric string value. Gifts are a
/// concrete example: GiftPackageReceived registers
/// "30" and GiftPackageReceivedImmediate
/// registers "31"; sending the enum name misses.
///
///
/// The default is numeric, and that matters. Both enum
/// overloads of the client's RegisterHandler
/// (OMHPBIEHOHN.PNDNMHHDOOJ) call Int32.ToString() on the id
/// and register ONLY that numeric string —
/// RecNet.Runtime/OMHPBIEHOHN.txt:662 and :733. There is no
/// enum-name companion key. The dispatcher does a plain case-sensitive
/// Dictionary.TryGetValue on the envelope's Id and returns
/// silently on a miss (OMHPBIEHOHN.txt:4052-4237), so a wrong key is
/// not an error anywhere — the push simply never arrives.
///
/// This used to default to id.ToString() (the enum NAME), which
/// silently dropped every enum-registered push that wasn't explicitly listed:
/// friend-request add/accept/remove and the RelationshipsInvalid refresh,
/// forced logout, maintenance broadcasts, live token-balance updates after a
/// level-up or purchase, consumable inventory changes, and invention
/// moderation state. Only the six ids below are genuinely string-keyed;
/// everything else must go out as a number. Verify against the binary before
/// adding an entry here — guessing wrong fails silently in both directions.
///
private static string WireName(PushNotificationId id) => id switch
{
// The ONLY ids registered under a bespoke string. Everything else is
// numeric — see the note below for why the default flipped.
PushNotificationId.SubscriptionUpdateProfile => "AccountUpdate",
PushNotificationId.SubscriptionUpdatePresence => "PresenceUpdate",
PushNotificationId.SubscriptionUpdateGameSession => "RoomInstanceUpdate",
PushNotificationId.SubscriptionUpdateRoom => "RoomUpdate",
PushNotificationId.ChatMessageReceived => "ChatMessageReceived",
PushNotificationId.CommunityBoardUpdate => "CommunityBoardUpdate",
// Numeric, because the client registered these through the enum
// overload. This is the default for a reason — see below.
_ => ((int)id).ToString(),
};
private static string Serialize(PushNotificationId id, object? msg) =>
JsonSerializer.Serialize(new { Id = WireName(id), Msg = msg }, Json);
/// Push a single notification to one player. No-op if the
/// player has no active connections.
public async Task NotifyAsync(long playerId, PushNotificationId id, object? msg = null)
{
var payload = Serialize(id, msg);
// Include the connection count so we can tell at a glance
// whether the push had a recipient. Zero connections = the
// watch isn't on SignalR right now (transport churn, just-
// logged-out session, etc.) and the message is dropped.
// The save flow specifically REQUIRES the watch to have a
// live SignalR connection at send time — without it,
// SubscriptionUpdateRoom evaporates into the hub and
// MasterUploadRoomDataBlobCoroutine waits forever on
// roomDetailsUpdated.
var connections = onlinePresence.GetConnections(playerId);
logger.LogInformation(
"[notify] sending id={Id} wireName={WireName} to player={PlayerId} bytes={Bytes} connections={ConnectionCount}",
id, WireName(id), playerId, payload.Length, connections.Count);
if (id == PushNotificationId.SubscriptionUpdateRoom)
{
logger.LogInformation(
"[notify] room-update payload player={PlayerId} json={Payload}",
playerId, payload.Length <= 6000 ? payload : payload[..6000] + "...");
}
await hub.Clients.Group(NotifyHub.GroupForPlayer(playerId))
.SendAsync(HubMethod, payload);
}
public async Task NotifyTypedAsync(long playerId, string notificationType, object? msg = null)
{
try
{
var payload = JsonSerializer.Serialize(new { Id = notificationType, Msg = msg }, Json);
await hub.Clients.Group(NotifyHub.GroupForPlayer(playerId))
.SendAsync(HubMethod, payload);
}
catch (Exception ex)
{
logger.LogWarning(ex,
"[notify] failed to send typed notification {Type} to player {PlayerId}",
notificationType, playerId);
}
}
/// Push fresh account payloads after a profile mutation.
/// The owner gets SelfAccountUpdate (private self DTO) and every
/// connected client gets AccountUpdate (public DTO). The 2023
/// client subscribes to both strings (RecNet.Runtime
/// OPMAPIOEIFG.txt:267 "AccountUpdate" → Action<Account>, :279
/// "SelfAccountUpdate" → Action<SelfAccount>) and refreshes its
/// cached account from the payload — the profile-edit PUTs return no
/// account, so this push is the ONLY way the new emoji / banner /
/// pronouns / identity flags reach the editor's own UI and anyone else
/// already looking at the profile. Sending AccountUpdate to the owner
/// alone (the previous behaviour) left every other player rendering the
/// stale cached account until relog.
public Task AccountChanged(PlayerEntity player)
{
var account = AccountDtos.Public(player);
var selfAccount = AccountDtos.Self(player);
return Task.WhenAll(
BroadcastAsync(PushNotificationId.SubscriptionUpdateProfile, account),
NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
/// Push a notification to every currently-connected
/// player. Used for global announcements and admin broadcasts.
public async Task BroadcastAsync(PushNotificationId id, object? msg = null)
{
await hub.Clients.All.SendAsync(HubMethod, Serialize(id, msg));
}
/// Push the same notification to every player in
/// . Used for "your friends are online"
/// fan-out.
public async Task NotifyManyAsync(
IEnumerable playerIds, PushNotificationId id, object? msg = null)
{
var groups = playerIds.Select(NotifyHub.GroupForPlayer).ToArray();
if (groups.Length == 0) return;
await hub.Clients.Groups(groups).SendAsync(HubMethod, Serialize(id, msg));
}
// ── Higher-level event helpers ─────────────────────────────────────
/// Push one relationship row to .
/// must be the recipient's OWN view of the
/// pair — the same {PlayerID, RelationshipType, Muted, Ignored,
/// Favorited} object api/relationships/v2/get returns. The 2023
/// handler (EEGNOHOELBG.FOCBJAIDCBB, Relationship
/// ECGNEHMCGCN) stores it under its PlayerID and raises the
/// changed event, which refreshes the friends list and the client's
/// SubscribeToPlayers set. Any other shape parses as PlayerID 0 and the
/// real row never updates.
public Task RelationshipChanged(long recipientPlayerId, object relationship) =>
NotifyAsync(recipientPlayerId, PushNotificationId.RelationshipChanged, relationship);
/// Push a presence delta so subscribed friends re-render
/// their friend-list row without waiting for the watch's 15-45 s
/// bulk-presence poll. The Msg body MUST match
/// RecNet.PlayerPresence's wire shape — the watch's
/// OnSubscriptionUpdatePresence handler (dump.cs:579282)
/// passes the raw dict straight into PlayerPresence.Deserialize
/// then UpdateCachedPresence. Anything other than that exact
/// shape (e.g. an ad-hoc { PlayerId, RoomId }) is silently
/// dropped client-side and the friend-list room stays stuck on
/// whatever was last bulk-fetched.
///
/// Builds the payload directly as JsonObject (instead of
/// boxing a through
/// as object) so the inner
/// camelCase keys don't depend on System.Text.Json's
/// polymorphic-property heuristics. With object?-typed Msg,
/// the runtime-type path can pick the wrong contract under certain
/// option combos and emit an empty {} body — which silently
/// drops the presence update at the watch.
public async Task PlayerPresenceChanged(
IEnumerable friendIds,
long playerId,
RoomInstanceDto? room,
bool? isOnline = null,
int statusVisibility = 0)
{
var groups = friendIds.Select(NotifyHub.GroupForPlayer).ToArray();
if (groups.Length == 0) return;
var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility);
logger.LogDebug(
"[notify] presence push player={PlayerId} → {FriendCount} friend(s), payload={Payload}",
playerId, groups.Length, payload);
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
}
/// Push one player's presence to every connection that subscribed
/// to them with SubscribeToPlayers, except
/// — the subject's own connections
/// (a frame about the local player moves them) and anyone already sent the
/// frame through their friend group.
public async Task PresenceToSubscribersAsync(
long playerId,
RoomInstanceDto? room,
bool isOnline,
int statusVisibility,
IReadOnlyList excludeConnectionIds)
{
var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility);
await hub.Clients.GroupExcept(NotifyHub.PresenceGroupForPlayer(playerId), excludeConnectionIds)
.SendAsync(HubMethod, payload);
}
/// The PresenceUpdate envelope for one player, as a JSON string.
///
/// is independent of the room: the 2023
/// presence DTO stores it in its own field (LCEHINCCHOB +0x50,
/// RecNet.Runtime/LCEHINCCHOB.txt:635-653) and DNKHCBFPLJG treats
/// "online with no room" as a real state — a player in the offline dorm
/// after matchmake/none. Deriving it from the room, as this used to, sent
/// such players to their friends as OFFLINE. When omitted it still falls
/// back to "has a room" for callers that only know about room moves.
/// An offline frame never carries a room.
public static string BuildPresenceUpdatePayload(
long playerId,
RoomInstanceDto? room,
bool? isOnline = null,
int statusVisibility = 0)
{
var online = isOnline ?? room is not null;
var redacted = online ? RoomInstanceDto.Redact(room) : null;
var msg = new System.Text.Json.Nodes.JsonObject
{
["playerId"] = (int)playerId,
// The player's stored /player/statusvisibility choice (default
// 0 = Everyone), the same value their own heartbeat reports.
["statusVisibility"] = statusVisibility,
["deviceClass"] = 0,
["vrMovementMode"] = 0,
["roomInstance"] = redacted is null
? null
: new System.Text.Json.Nodes.JsonObject
{
["roomInstanceId"] = redacted.RoomInstanceId,
["roomId"] = redacted.RoomId,
["subRoomId"] = redacted.SubRoomId,
["roomInstanceType"] = redacted.RoomInstanceType,
["location"] = redacted.Location,
["dataBlob"] = redacted.DataBlob,
["eventId"] = redacted.EventId,
["clubId"] = redacted.ClubId,
["roomCode"] = redacted.RoomCode,
["photonRegionId"] = redacted.PhotonRegionId,
["photonRoomId"] = redacted.PhotonRoomId,
["name"] = redacted.NameWire,
["maxCapacity"] = redacted.MaxCapacity,
["isFull"] = redacted.IsFull,
["isPrivate"] = redacted.IsPrivate,
["isInProgress"] = redacted.IsInProgress,
["EncryptVoiceChat"] = redacted.EncryptVoiceChat,
},
// 2020.12 PlayerPresence.Deserialize requires these.
["isOnline"] = online,
// 2023.03.21 client build version; must match the joining
// client's own version or it reports a version mismatch. MUST be
// a STRING ("20230317"): the client's presence DTO reads
// appVersion with a string reader, so an int crashes the whole
// SignalR message with "expected:'String Begin Token',
// actual:'20230317'" — which aborts the presence/room update
// (e.g. surfaced as "failed to create a new room").
["appVersion"] = "20230317",
};
var envelope = new System.Text.Json.Nodes.JsonObject
{
// String dispatch key (NOT the int enum value) —
// see WireName for rationale.
["Id"] = WireName(PushNotificationId.SubscriptionUpdatePresence),
["Msg"] = msg,
};
return envelope.ToJsonString();
}
public Task PlayerCameOnline(IEnumerable friendIds, long playerId, RoomInstanceDto? room) =>
PlayerPresenceChanged(friendIds, playerId, room);
public Task PlayerWentOffline(IEnumerable friendIds, long playerId) =>
PlayerPresenceChanged(friendIds, playerId, null);
public Task PlayerEnteredRoom(IEnumerable friendIds, long playerId, RoomInstanceDto room) =>
PlayerPresenceChanged(friendIds, playerId, room);
/// Client logout reasons (Logout "6" payload Reason,
/// enum MPJOEALLGHE, read by formatter PBEDEFHAHID). The
/// value must go out as a NUMBER: release builds obfuscate enum names, so a
/// string like "AdminPasswordReset" fails the parse and the whole push is
/// dropped. SessionManager.JNNAOHMNGEM shows its own "signed in
/// elsewhere" text for , then returns the
/// client to the boot scene for every reason.
public enum LogoutReason
{
Unknown = 0,
UserInitiated = 1,
SessionTakeover = 2,
ForciblyLoggedOut = 3,
Banned = 4,
}
/// Log every session of out to the
/// boot scene. Use this, not a kick, when the player must not stay signed
/// in: a ModerationKick only sends them to their dorm.
public Task LogoutAsync(long playerId, LogoutReason reason) =>
NotifyAsync(playerId, PushNotificationId.Logout, new { Reason = (int)reason });
/// Single-session-per-account: when a second login replaces the
/// LoginLock, log the older session out. The new session hasn't called
/// negotiate yet (it happens AFTER /player/login on the boot
/// sequence), so only the old session's connections receive it.
public Task LogoutStaleSession(long playerId) =>
LogoutAsync(playerId, LogoutReason.SessionTakeover);
/// The same takeover logout, sent to specific SignalR connection
/// ids — used by
/// to evict the previous session when a new device connects on the same
/// account. Targets ids (not the player group) so the brand-new
/// connection, which joins the group right after, never receives it. Works
/// across replicas because the Redis backplane routes
/// Clients.Clients(ids) to whichever node owns each id.
public Task LogoutConnectionsAsync(IReadOnlyCollection connectionIds)
{
if (connectionIds.Count == 0) return Task.CompletedTask;
var payload = Serialize(PushNotificationId.Logout, new { Reason = (int)LogoutReason.SessionTakeover });
return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload);
}
/// Kick back to their dorm with
/// shown in the kick dialog. The player stays
/// signed in; use for bans and deletions.
/// Pass the room instance the player is in as
/// to have the client announce the kick
/// to that room.
public Task KickPlayerAsync(long playerId, string reason, long gameSessionId = 0) =>
NotifyAsync(playerId, PushNotificationId.ModerationKick, ModerationKickPayload(reason, gameSessionId));
/// ModerationKick "22" payload: the FLAT moderation block
/// PIBLFGPOCIB (formatter LAIJIAIHGCO). The kick handler
/// (SessionManager HandleModerationKicked) starts from its own
/// "Kicked to Dormroom" text and only adds a reason as
/// a "Reason: " line from VoteKickReason when ReportCategory is 10
/// (VoteKick) and IsHostKick is false; Message is never
/// shown. When GameSessionId is the kicked player's current room
/// instance it also broadcasts the kick to that room, and
/// Duration > 0 starts a timeout, so it stays 0. The old nested
/// {Reason:{…}} shape (from the 2020 client) parsed to all defaults,
/// which booted the player but dropped every reason.
private static object ModerationKickPayload(string reason, long gameSessionId)
{
var message = string.IsNullOrWhiteSpace(reason) ? "Kicked to Dormroom" : reason.Trim();
return new
{
ReportCategory = 10, // VoteKick: the only category that shows a reason
Duration = 0,
GameSessionId = gameSessionId,
IsHostKick = false,
Message = message,
PlayerIdReporter = (int?)null,
IsBan = false,
IsVoiceModAutoban = false,
IsWarning = false,
VoteKickReason = message,
TimeoutStartedAt = (DateTime?)null,
};
}
}