Refactor moderation notifications: replace NotifyAsync with KickPlayerAsync for player kicks in InGameModerationController and AdminController; update NotificationService to streamline moderation kick payloads.

This commit is contained in:
Alexa
2026-06-02 02:48:04 +03:00
parent 96eb368d41
commit 24c8a920e4
3 changed files with 49 additions and 38 deletions
@@ -136,12 +136,9 @@ public class InGameModerationController(
foreach (var target in ids) foreach (var target in ids)
{ {
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target); logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
// Push a SignalR ModerationKick notification — the watch's // Push the ModerationKick shape the 2020.12 client expects:
// PlayerReporting.OnModerationKick handler boots the player // Msg.Reason must be a ModerationBlockDetail object.
// back to their dorm. await notifications.KickPlayerAsync(target, "Instant kick by moderator.");
await notifications.NotifyAsync(
target,
Models.Notification.PushNotificationId.ModerationKick);
} }
return Ok(new { success = true, error = "" }); return Ok(new { success = true, error = "" });
} }
@@ -159,8 +159,8 @@ public class AdminController(
await LogAsync("ban_player", "player", id, body.Reason ?? ""); await LogAsync("ban_player", "player", id, body.Reason ?? "");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.NotifyAsync(id, PushNotificationId.ModerationKick, await notifications.KickPlayerAsync(id, body.Reason ?? "You have been banned.");
new { Reason = body.Reason ?? "", Until = player.BannedUntil }); playerPresence.Clear(id);
return Ok(new { player.Id, player.BannedUntil }); return Ok(new { player.Id, player.BannedUntil });
} }
@@ -1269,7 +1269,7 @@ public class AdminController(
{ {
var r = playerPresence.GetRoom(pid); var r = playerPresence.GetRoom(pid);
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue; if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
await notifications.NotifyAsync(pid, PushNotificationId.ModerationKick, new { Reason = reason }); await notifications.KickPlayerAsync(pid, reason);
playerPresence.Clear(pid); playerPresence.Clear(pid);
kicked.Add(pid); kicked.Add(pid);
} }
@@ -1616,8 +1616,11 @@ public class AdminController(
[HttpPost("players/{id:long}/kick")] [HttpPost("players/{id:long}/kick")]
public async Task<ActionResult> KickPlayer(long id, [FromBody] KickRequest? body) public async Task<ActionResult> KickPlayer(long id, [FromBody] KickRequest? body)
{ {
await notifications.NotifyAsync(id, PushNotificationId.ModerationKick, var reason = string.IsNullOrWhiteSpace(body?.Reason)
new { Reason = body?.Reason ?? "" }); ? "Kicked by an admin."
: body!.Reason!.Trim();
await notifications.KickPlayerAsync(id, reason);
playerPresence.Clear(id);
await LogAsync("kick_player", "player", id, body?.Reason ?? ""); await LogAsync("kick_player", "player", id, body?.Reason ?? "");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new { kicked = id }); return Ok(new { kicked = id });
+38 -27
View File
@@ -329,14 +329,10 @@ public class NotificationService(
/// <paramref name="playerId"/> to disconnect via the watch's /// <paramref name="playerId"/> to disconnect via the watch's
/// ModerationKick flow. Wire path verified against decompilation: /// ModerationKick flow. Wire path verified against decompilation:
/// <c>PushNotificationId.ModerationKick = 22</c> is registered /// <c>PushNotificationId.ModerationKick = 22</c> is registered
/// at <c>PlayerReporting.txt:244</c> via the enum-overload, so /// by <c>LCCEEFHOBEN.OFFLOPLJBBG</c> via the enum overload.
/// the wire name resolves to <c>"ModerationKick"</c>. /// <c>LCCEEFHOBEN.KEIAFMLIMFA(Dictionary)</c> reads
/// <c>OnModerationKick(Dictionary)</c> at /// <c>Msg.Reason</c> as a ModerationBlockDetail object, then
/// <c>PlayerReporting.txt:1857</c> shows a confirmation dialog /// calls <c>PODFHPNOHGB</c> / <c>BootLocalPlayerToDormRoom</c>.
/// then calls <c>LocalModerationKickSelf</c>
/// (<c>PlayerReporting.txt:2647</c>), which queues the
/// disconnect through <c>Core.ShutdownForLogout</c> — the same
/// graceful path the watch uses for real moderator kicks.
/// ///
/// Used by <see cref="Controllers.Match.MatchPlayerController.PlayerLogin"/> /// Used by <see cref="Controllers.Match.MatchPlayerController.PlayerLogin"/>
/// to enforce single-session-per-account: when a second login on /// to enforce single-session-per-account: when a second login on
@@ -347,18 +343,11 @@ public class NotificationService(
/// </summary> /// </summary>
public Task KickStaleSession(long playerId, string reason) => public Task KickStaleSession(long playerId, string reason) =>
NotifyAsync(playerId, PushNotificationId.ModerationKick, NotifyAsync(playerId, PushNotificationId.ModerationKick,
new ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
{
// Field names follow PlayerReporting's wire convention public Task KickPlayerAsync(long playerId, string reason, bool isBan = false) =>
// for moderation-related dialogs; the watch's NotifyAsync(playerId, PushNotificationId.ModerationKick,
// OnModerationKick deserializes via Util.GetKeyOrDefault ModerationKickPayload(reason, isBan, source: "admin_kick"));
// so missing fields are tolerated, but populating them
// makes the dialog read sensibly.
Reason = reason,
DisplayReason = reason,
BannedByPlayerId = 1, // Coach
Source = "session_replaced",
});
/// <summary>Push a ModerationKick to a specific set of SignalR /// <summary>Push a ModerationKick to a specific set of SignalR
/// connection ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/> /// connection ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/>
@@ -371,16 +360,38 @@ public class NotificationService(
public Task KickConnectionsAsync(IReadOnlyCollection<string> connectionIds, string reason) public Task KickConnectionsAsync(IReadOnlyCollection<string> connectionIds, string reason)
{ {
if (connectionIds.Count == 0) return Task.CompletedTask; if (connectionIds.Count == 0) return Task.CompletedTask;
var payload = Serialize(PushNotificationId.ModerationKick, new var payload = Serialize(PushNotificationId.ModerationKick,
{ ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
Reason = reason,
DisplayReason = reason,
BannedByPlayerId = 1,
Source = "session_replaced",
});
return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload); 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,
};
}
/// <summary>Notify a private-instance owner that someone tried to /// <summary>Notify a private-instance owner that someone tried to
/// follow them in via "Go to Player" but was rejected for not /// follow them in via "Go to Player" but was rejected for not
/// being on the invite list. Reuses the MessageReceived channel /// being on the invite list. Reuses the MessageReceived channel