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. /// private static string WireName(PushNotificationId id) => id switch { // Custom short names — registered under a bespoke string, // no integer companion in the dispatch dict. PushNotificationId.SubscriptionUpdatePresence => "PresenceUpdate", PushNotificationId.SubscriptionUpdateGameSession => "RoomInstanceUpdate", PushNotificationId.SubscriptionUpdateProfile => "AccountUpdate", // Int-only registrations — sending the enum name misses. PushNotificationId.MessageReceived => "2", PushNotificationId.MessageDeleted => "3", PushNotificationId.ModerationKick => "22", PushNotificationId.ModerationKickAttemptFailed => "23", PushNotificationId.ModerationRoomBan => "24", PushNotificationId.GiftPackageReceived => "30", PushNotificationId.GiftPackageReceivedImmediate => "31", PushNotificationId.PlayerEventCreated => "80", PushNotificationId.PlayerEventUpdated => "81", PushNotificationId.PlayerEventDeleted => "82", PushNotificationId.PlayerEventResponseChanged => "83", PushNotificationId.PlayerEventResponseDeleted => "84", PushNotificationId.PlayerEventStateChanged => "85", // Everything else: enum.ToString() matches because the watch's // enum-overload RegisterHandler registers both forms. _ => 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 to the affected player's /// watch. The 2020 client caches Account/SelfAccount objects in memory; /// without this, username and display-name edits persist server-side /// but the current session keeps rendering the stale cached names until /// a full relog rebuilds the account cache. public Task AccountChanged(PlayerEntity player) { var account = new RecNetAccount { AccountId = (int)player.Id, RawUsername = player.Username, Username = player.Username, DisplayName = string.IsNullOrWhiteSpace(player.DisplayName) ? player.Username : player.DisplayName, ProfileImage = player.ProfileImageName ?? string.Empty, TreatAsJunior = player.IsJunior, HasBirthday = true, Platforms = 1, CreatedAt = player.CreatedAt, }; var selfAccount = new RecNetSelfAccount { AccountId = account.AccountId, RawUsername = account.RawUsername, Username = account.Username, DisplayName = account.DisplayName, ProfileImage = account.ProfileImage, TreatAsJunior = account.TreatAsJunior, HasBirthday = account.HasBirthday, Platforms = account.Platforms, CreatedAt = account.CreatedAt, Email = player.Email ?? string.Empty, Phone = player.Phone ?? string.Empty, Birthday = player.Birthday ?? new DateTime(2000, 1, 1), JuniorState = player.IsJunior ? 1 : 0, ParentAccountId = null, }; return Task.WhenAll( NotifyAsync(player.Id, 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 ───────────────────────────────────── public Task FriendRequestReceived(long targetPlayerId, long fromPlayerId) => NotifyAsync(targetPlayerId, PushNotificationId.RelationshipChanged, new { Reason = "FriendRequestReceived", From = fromPlayerId }); public Task FriendRequestAccepted(long requesterPlayerId, long byPlayerId) => NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged, new { Reason = "FriendRequestAccepted", By = byPlayerId }); public Task FriendRequestDeclined(long requesterPlayerId, long byPlayerId) => NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged, new { Reason = "FriendRequestDeclined", By = byPlayerId }); public Task FriendRemoved(long otherPlayerId, long byPlayerId) => NotifyAsync(otherPlayerId, PushNotificationId.RelationshipChanged, new { Reason = "FriendRemoved", By = byPlayerId }); public Task FriendBlocked(long blockedPlayerId, long byPlayerId) => NotifyAsync(blockedPlayerId, PushNotificationId.RelationshipChanged, new { Reason = "FriendBlocked", By = byPlayerId }); /// 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) { var groups = friendIds.Select(NotifyHub.GroupForPlayer).ToArray(); if (groups.Length == 0) return; var redacted = RoomInstanceDto.Redact(room); var msg = new System.Text.Json.Nodes.JsonObject { ["playerId"] = (int)playerId, // Everyone=0 (was 1=FriendsOnly, which hid you from non-friends). ["statusVisibility"] = 0, ["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"] = redacted is not null, // 2023.03.21 client build version ("20230317"); must match the // joining client's own version or it reports a version mismatch. // Stale 2020 value (20201210) carried over from the December branch. ["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, }; var payload = envelope.ToJsonString(); logger.LogDebug( "[notify] presence push player={PlayerId} → {FriendCount} friend(s), payload={Payload}", playerId, groups.Length, payload); await hub.Clients.Groups(groups).SendAsync(HubMethod, payload); } 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); /// Force every currently-connected SignalR session for /// to disconnect via the watch's /// ModerationKick flow. Wire path verified against decompilation: /// PushNotificationId.ModerationKick = 22 is registered /// by LCCEEFHOBEN.OFFLOPLJBBG via the enum overload. /// LCCEEFHOBEN.KEIAFMLIMFA(Dictionary) reads /// Msg.Reason as a ModerationBlockDetail object, then /// calls PODFHPNOHGB / BootLocalPlayerToDormRoom. /// /// Used by /// to enforce single-session-per-account: when a second login on /// the same account replaces the LoginLock, the older SignalR /// connections are kicked. The new session hasn't called /// negotiate yet (it happens AFTER /player/login on the /// boot sequence), so the kick only hits old sessions. /// public Task KickStaleSession(long playerId, string reason) => NotifyAsync(playerId, PushNotificationId.ModerationKick, ModerationKickPayload(reason, isBan: false, source: "session_replaced")); public Task KickPlayerAsync(long playerId, string reason, bool isBan = false) => NotifyAsync(playerId, PushNotificationId.ModerationKick, ModerationKickPayload(reason, isBan, source: "admin_kick")); /// Push a ModerationKick to a specific set of SignalR /// connection ids — used by /// to evict the previous session when a new device connects on the /// same account. Targets specific ids (not the player group) so /// the brand-new connection joining the group right after this /// returns can't accidentally receive its own eviction notice. /// Works across replicas because SignalR's Redis backplane routes /// Clients.Clients(ids) to whichever node owns each id. public Task KickConnectionsAsync(IReadOnlyCollection connectionIds, string reason) { if (connectionIds.Count == 0) return Task.CompletedTask; var payload = Serialize(PushNotificationId.ModerationKick, ModerationKickPayload(reason, isBan: false, source: "session_replaced")); return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload); } private static object ModerationKickPayload(string reason, bool isBan, string source) { var message = string.IsNullOrWhiteSpace(reason) ? "Kicked to Dormroom" : reason.Trim(); var blockDetail = new { ReportCategory = 10, // PlayerReporting.ReportCategory.VoteKick Duration = 0, GameSessionId = 0L, Message = message, IsHostKick = true, PlayerIdReporter = 1, IsBan = isBan, VoteKickReason = message, }; return new { Reason = blockDetail, DisplayReason = message, BannedByPlayerId = 1, Source = source, }; } /// Notify a private-instance owner that someone tried to /// follow them in via "Go to Player" but was rejected for not /// being on the invite list. Reuses the MessageReceived channel /// since the watch already renders that as an inbox toast — no /// new client-side handler needed. The body wording surfaces it /// as "Player X tried to join your private match" so the owner /// knows to send an explicit invite if they want to let them in. public Task PrivateInstanceJoinRejected(long ownerPlayerId, long attemptedByPlayerId) => NotifyAsync(ownerPlayerId, PushNotificationId.MessageReceived, new { Reason = "PrivateInstanceJoinRejected", AttemptedBy = attemptedByPlayerId, Preview = $"Player {attemptedByPlayerId} tried to join your private match", }); }