diff --git a/Content.Server/Administration/Commands/BanCommand.cs b/Content.Server/Administration/Commands/BanCommand.cs index a4e89e093b..3f978c6dad 100644 --- a/Content.Server/Administration/Commands/BanCommand.cs +++ b/Content.Server/Administration/Commands/BanCommand.cs @@ -1,5 +1,5 @@ -using System.Linq; using Content.Server.Administration.Managers; +using Content.Server.UtkaIntegration; using Content.Shared.Administration; using Content.Shared.CCVar; using Content.Shared.Database; @@ -15,7 +15,7 @@ public sealed class BanCommand : LocalizedCommands [Dependency] private readonly IPlayerLocator _locator = default!; [Dependency] private readonly IBanManager _bans = default!; [Dependency] private readonly IConfigurationManager _cfg = default!; - [Dependency] private readonly IPlayerManager _playerManager = default!; + [Dependency] private readonly UtkaTCPWrapper _utkaSockets = default!; // WD public override string Command => "ban"; @@ -106,6 +106,19 @@ public sealed class BanCommand : LocalizedCommands var targetHWid = located.LastHWId; _bans.CreateServerBan(targetUid, target, player?.UserId, null, targetHWid, minutes, severity, reason, isGlobalBan); + + //WD start + var utkaBanned = new UtkaBannedEvent() + { + Ckey = target, + ACkey = player?.Name, + Bantype = "server", + Duration = minutes, + Global = isGlobalBan, + Reason = reason + }; + _utkaSockets.SendMessageToAll(utkaBanned); + //WD end } public override CompletionResult GetCompletion(IConsoleShell shell, string[] args) diff --git a/Content.Server/Administration/Managers/RoleBanManager.cs b/Content.Server/Administration/Managers/RoleBanManager.cs new file mode 100644 index 0000000000..05b2f82d41 --- /dev/null +++ b/Content.Server/Administration/Managers/RoleBanManager.cs @@ -0,0 +1,290 @@ +using System.Collections.Immutable; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using Content.Server.Database; +using Content.Server.UtkaIntegration; +using Content.Server.White; +using Content.Shared.CCVar; +using Content.Shared.Roles; +using Robust.Server.Player; +using Robust.Shared.Configuration; +using Robust.Shared.Console; +using Robust.Shared.Enums; +using Robust.Shared.Network; +using Robust.Shared.Prototypes; + +namespace Content.Server.Administration.Managers; + +public sealed class RoleBanManager +{ + [Dependency] private readonly IServerDbManager _db = default!; + [Dependency] private readonly IPlayerManager _playerManager = default!; + [Dependency] private readonly IPrototypeManager _prototypeManager = default!; + [Dependency] private readonly IPlayerLocator _playerLocator = default!; + [Dependency] private readonly UtkaTCPWrapper _utkaSockets = default!; // WD + + private const string JobPrefix = "Job:"; + + private readonly Dictionary> _cachedRoleBans = new(); + + public void Initialize() + { + _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; + } + + private async void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) + { + if (e.NewStatus != SessionStatus.Connected + || _cachedRoleBans.ContainsKey(e.Session.UserId)) + return; + + var netChannel = e.Session.ConnectedClient; + await CacheDbRoleBans(e.Session.UserId, netChannel.RemoteEndPoint.Address, netChannel.UserData.HWId.Length == 0 ? null : netChannel.UserData.HWId); + } + + private async Task AddRoleBan(ServerRoleBanDef banDef) + { + if (banDef.UserId != null) + { + if (!_cachedRoleBans.TryGetValue(banDef.UserId.Value, out var roleBans)) + { + roleBans = new HashSet(); + _cachedRoleBans.Add(banDef.UserId.Value, roleBans); + } + if (!roleBans.Contains(banDef)) + roleBans.Add(banDef); + } + + await _db.AddServerRoleBanAsync(banDef); + return true; + } + + public HashSet? GetRoleBans(NetUserId playerUserId) + { + return _cachedRoleBans.TryGetValue(playerUserId, out var roleBans) ? roleBans.Select(banDef => banDef.Role).ToHashSet() : null; + } + + private async Task CacheDbRoleBans(NetUserId userId, IPAddress? address = null, ImmutableArray? hwId = null) + { + var roleBans = await _db.GetServerRoleBansAsync(address, userId, hwId, false); + + var userRoleBans = new HashSet(); + foreach (var ban in roleBans) + { + userRoleBans.Add(ban); + } + + _cachedRoleBans[userId] = userRoleBans; + } + + public void Restart() + { + // Clear out players that have disconnected. + var toRemove = new List(); + foreach (var player in _cachedRoleBans.Keys) + { + if (!_playerManager.TryGetSessionById(player, out _)) + toRemove.Add(player); + } + + foreach (var player in toRemove) + { + _cachedRoleBans.Remove(player); + } + + // Check for expired bans + foreach (var (_, roleBans) in _cachedRoleBans) + { + roleBans.RemoveWhere(ban => DateTimeOffset.Now > ban.ExpirationTime); + } + } + + #region Job Bans + public async void CreateJobBan(IConsoleShell shell, string target, string job, string reason, uint minutes, bool isGlobalBan) + { + if (!_prototypeManager.TryIndex(job, out JobPrototype? _)) + { + shell.WriteError(Loc.GetString("cmd-roleban-job-parse", ("job", job))); + return; + } + + var role = string.Concat(JobPrefix, job); // WD edit + CreateRoleBan(shell, target, role, reason, minutes, isGlobalBan, job); // WD edit + } + + //WD start + public async void UtkaCreateJobBan(string admin, string target, string job, string reason, uint minutes, bool isGlobalBan) + { + var role = string.Concat(JobPrefix, job); + + var located = await _playerLocator.LookupIdByNameOrIdAsync(target); + if (located == null) + { + UtkaSendResponse(false); + return; + } + + var targetUid = located.UserId; + var targetHWid = located.LastHWId; + var targetAddress = located.LastAddress; + + DateTimeOffset? expires = null; + if (minutes > 0) + { + expires = DateTimeOffset.Now + TimeSpan.FromMinutes(minutes); + } + + (IPAddress, int)? addressRange = null; + if (targetAddress != null) + { + if (targetAddress.IsIPv4MappedToIPv6) + targetAddress = targetAddress.MapToIPv4(); + + // Ban /64 for IPv4, /32 for IPv4. + var cidr = targetAddress.AddressFamily == AddressFamily.InterNetworkV6 ? 64 : 32; + addressRange = (targetAddress, cidr); + } + + var cfg = UnsafePseudoIoC.ConfigurationManager; + var serverName = cfg.GetCVar(CCVars.AdminLogsServerName); + + if (isGlobalBan) + { + serverName = "unknown"; + } + + var locatedPlayer = await _playerLocator.LookupIdByNameOrIdAsync(admin); + if (locatedPlayer == null) + { + UtkaSendResponse(false); + return; + } + + var player = locatedPlayer.UserId; + var banDef = new ServerRoleBanDef( + null, + targetUid, + addressRange, + targetHWid, + DateTimeOffset.Now, + expires, + reason, + player, + null, + role, + serverName); + + UtkaSendJobBanEvent(admin, target, minutes, job, isGlobalBan, reason); + UtkaSendResponse(true); + } + //WD end + + public HashSet? GetJobBans(NetUserId playerUserId) + { + if (!_cachedRoleBans.TryGetValue(playerUserId, out var roleBans)) + return null; + return roleBans + .Where(ban => ban.Role.StartsWith(JobPrefix, StringComparison.Ordinal)) + .Select(ban => ban.Role[JobPrefix.Length..]) + .ToHashSet(); + } + #endregion + + #region Commands + private async void CreateRoleBan(IConsoleShell shell, string target, string role, string reason, uint minutes, bool isGlobalBan, string? job = null) // WD edit + { + var located = await _playerLocator.LookupIdByNameOrIdAsync(target); + if (located == null) + { + shell.WriteError(Loc.GetString("cmd-roleban-name-parse")); + return; + } + + var targetUid = located.UserId; + var targetHWid = located.LastHWId; + var targetAddress = located.LastAddress; + + DateTimeOffset? expires = null; + if (minutes > 0) + { + expires = DateTimeOffset.Now + TimeSpan.FromMinutes(minutes); + } + + (IPAddress, int)? addressRange = null; + if (targetAddress != null) + { + if (targetAddress.IsIPv4MappedToIPv6) + targetAddress = targetAddress.MapToIPv4(); + + // Ban /64 for IPv4, /32 for IPv4. + var cidr = targetAddress.AddressFamily == AddressFamily.InterNetworkV6 ? 64 : 32; + addressRange = (targetAddress, cidr); + } + + var cfg = UnsafePseudoIoC.ConfigurationManager; + var serverName = cfg.GetCVar(CCVars.AdminLogsServerName); + + if (isGlobalBan) + { + serverName = "unknown"; + } + + var player = shell.Player as IPlayerSession; + var banDef = new ServerRoleBanDef( + null, + targetUid, + addressRange, + targetHWid, + DateTimeOffset.Now, + expires, + reason, + player?.UserId, + null, + role, + serverName); + + if (!await AddRoleBan(banDef)) + { + shell.WriteLine(Loc.GetString("cmd-roleban-existing", ("target", target), ("role", role))); + return; + } + + var length = expires == null ? Loc.GetString("cmd-roleban-inf") : Loc.GetString("cmd-roleban-until", ("expires", expires)); + shell.WriteLine(Loc.GetString("cmd-roleban-success", ("target", target), ("role", role), ("reason", reason), ("length", length), ("server", serverName))); + + if (job != null) // WD + UtkaSendJobBanEvent(shell.Player!.Name, target, minutes, job, isGlobalBan, reason); //WD + } + #endregion + + //WD start + private void UtkaSendResponse(bool banned) + { + var utkaBanned = new UtkaJobBanResponse() + { + Banned = banned + }; + + _utkaSockets.SendMessageToAll(utkaBanned); + } + + private void UtkaSendJobBanEvent(string ackey, string ckey, uint duration, string role, bool global, + string reason) + { + var utkaBanned = new UtkaBannedEvent() + { + ACkey = ackey, + Ckey = ckey, + Duration = duration, + Bantype = role, + Global = global, + Reason = reason + }; + + _utkaSockets.SendMessageToAll(utkaBanned); + } + //WD end +} diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index 2a22a4b9a9..ad4c10d2b9 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -8,6 +8,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using Content.Server.Administration.Managers; using Content.Server.GameTicking; +using Content.Server.UtkaIntegration; using Content.Shared.Administration; using Content.Shared.CCVar; using Content.Shared.Mind; @@ -33,6 +34,7 @@ namespace Content.Server.Administration.Systems [Dependency] private readonly IPlayerLocator _playerLocator = default!; [Dependency] private readonly GameTicker _gameTicker = default!; [Dependency] private readonly SharedMindSystem _minds = default!; + [Dependency] private readonly UtkaTCPWrapper _utkaSockets = default!; // WD private ISawmill _sawmill = default!; private readonly HttpClient _httpClient = new(); @@ -474,6 +476,12 @@ namespace Content.Server.Administration.Systems _messageQueues[msg.UserId].Enqueue(GenerateAHelpMessage(senderSession.Name, str, !personalChannel, _gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"), _gameTicker.RunLevel, admins.Count == 0)); } + // WD start + var utkaCkey = _playerManager.GetSessionByUserId(message.UserId).ConnectedClient.UserName; + var utkaSender = _playerManager.GetSessionByUserId(senderSession.UserId).ConnectedClient.UserName; + UtkaSendAhelpPm(message.Text, utkaCkey, utkaSender); + // WD end + if (admins.Count != 0 || sendsWebhook) return; @@ -619,6 +627,21 @@ namespace Content.Server.Administration.Systems _messageQueues[msg.UserId].Enqueue(GenerateAHelpMessage(sender, str, true, _gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"), _gameTicker.RunLevel)); } + + var utkaCkey = _playerManager.GetSessionByUserId(receiver).ConnectedClient.UserName; + UtkaSendAhelpPm(text, utkaCkey, sender); + } + + private void UtkaSendAhelpPm(string message, string ckey, string sender) + { + var utkaAhelpEvent = new UtkaAhelpPmEvent() + { + Message = message, + Ckey = ckey, + Sender = sender + }; + + _utkaSockets.SendMessageToAll(utkaAhelpEvent); } //WD-EDIT } diff --git a/Content.Server/GameTicking/GameTicker.Player.cs b/Content.Server/GameTicking/GameTicker.Player.cs index decb9e3b7c..8304956b54 100644 --- a/Content.Server/GameTicking/GameTicker.Player.cs +++ b/Content.Server/GameTicking/GameTicker.Player.cs @@ -1,4 +1,5 @@ using Content.Server.Database; +using Content.Server.UtkaIntegration; using Content.Shared.GameTicking; using Content.Shared.GameWindow; using Content.Shared.Players; @@ -16,6 +17,7 @@ namespace Content.Server.GameTicking { [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IServerDbManager _dbManager = default!; + [Dependency] private readonly UtkaTCPWrapper _utkaSockets = default!; // WD private void InitializePlayer() { @@ -65,6 +67,14 @@ namespace Content.Server.GameTicking ? Loc.GetString("player-first-join-message", ("name", args.Session.Name)) : Loc.GetString("player-join-message", ("name", args.Session.Name))); + //WD start + var utkaPlayerJoined = new UtkaPlayerJoinedEvent() + { + Ckey = args.Session.Name + }; + _utkaSockets.SendMessageToAll(utkaPlayerJoined); + //WD end + if (LobbyEnabled && _roundStartCountdownHasNotStartedYetDueToNoPlayers) { _roundStartCountdownHasNotStartedYetDueToNoPlayers = false; @@ -123,6 +133,14 @@ namespace Content.Server.GameTicking mind.Session = null; } + //WD start + var utkaPlayerLeft = new UtkaPlayerLeftEvent() + { + Ckey = args.Session.Name + }; + _utkaSockets.SendMessageToAll(utkaPlayerLeft); + //WD end + if (_playerGameStatuses.ContainsKey(args.Session.UserId)) //WD-EDIT _userDb.ClientDisconnected(session); break; diff --git a/Content.Server/UtkaIntegration/Commands/UtkaBanCommand.cs b/Content.Server/UtkaIntegration/Commands/UtkaBanCommand.cs new file mode 100644 index 0000000000..607068544f --- /dev/null +++ b/Content.Server/UtkaIntegration/Commands/UtkaBanCommand.cs @@ -0,0 +1,129 @@ +using System.Net; +using System.Net.Sockets; +using Content.Server.Administration; +using Content.Server.Database; +using Content.Shared.CCVar; +using Robust.Server.Player; +using Robust.Shared.Configuration; + +namespace Content.Server.UtkaIntegration; + +public sealed class UtkaBanCommand : IUtkaCommand +{ + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IPlayerManager _playerManager = default!; + [Dependency] private UtkaTCPWrapper _utkaSocketWrapper = default!; + + private const ILocalizationManager LocalizationManager = default!; + + public string Name => "ban"; + public Type RequestMessageType => typeof(UtkaBanRequest); + public async void Execute(UtkaTCPSession session, UtkaBaseMessage baseMessage) + { + if (baseMessage is not UtkaBanRequest message) return; + + var plyMgr = IoCManager.Resolve(); + var locator = IoCManager.Resolve(); + var dbMan = IoCManager.Resolve(); + IoCManager.InjectDependencies(this); + + //_playerManager.TryGetSessionByUsername(message.ACkey!, out var player); + var locatedPlayer = await locator.LookupIdByNameOrIdAsync(message.ACkey!); + if (locatedPlayer == null) + { + UtkaSendResponse(false); + return; + } + + var player = locatedPlayer.UserId; + + var target = message.Ckey!; + var reason = message.Reason!; + var minutes = (uint) message.Duration!; + var isGlobalBan = (bool) message.Global!; + + var located = await locator.LookupIdByNameOrIdAsync(target); + if (located == null) + { + UtkaSendResponse(false); + return; + } + + var targetUid = located.UserId; + var targetHWid = located.LastHWId; + var targetAddr = located.LastAddress; + + if (player == targetUid) + { + UtkaSendResponse(false); + return; + } + + DateTimeOffset? expires = null; + if (minutes > 0) + { + expires = DateTimeOffset.Now + TimeSpan.FromMinutes(minutes); + } + + (IPAddress, int)? addrRange = null; + if (targetAddr != null) + { + if (targetAddr.IsIPv4MappedToIPv6) + targetAddr = targetAddr.MapToIPv4(); + + // Ban /64 for IPv4, /32 for IPv4. + var cidr = targetAddr.AddressFamily == AddressFamily.InterNetworkV6 ? 64 : 32; + addrRange = (targetAddr, cidr); + } + + var serverName = _cfg.GetCVar(CCVars.AdminLogsServerName); + + if (isGlobalBan) + { + serverName = "unknown"; + } + + var banDef = new ServerBanDef( + null, + targetUid, + addrRange, + targetHWid, + DateTimeOffset.Now, + expires, + reason, + player, + null, + serverName); + + await dbMan.AddServerBanAsync(banDef); + + if (plyMgr.TryGetSessionById(targetUid, out var targetPlayer)) + { + var msg = banDef.FormatBanMessage(_cfg, LocalizationManager); + targetPlayer.ConnectedClient.Disconnect(msg); + } + + UtkaSendResponse(true); + + var utkaBanned = new UtkaBannedEvent() + { + Ckey = message.Ckey, + ACkey = message.ACkey, + Bantype = "server", + Duration = message.Duration, + Global = message.Global, + Reason = message.Reason + }; + _utkaSocketWrapper.SendMessageToAll(utkaBanned); + } + + private void UtkaSendResponse(bool banned) + { + var utkaResponse = new UtkaBanResponse() + { + Banned = banned + }; + + _utkaSocketWrapper.SendMessageToAll(utkaResponse); + } +} diff --git a/Content.Server/UtkaIntegration/Commands/UtkaJobBanCommand.cs b/Content.Server/UtkaIntegration/Commands/UtkaJobBanCommand.cs new file mode 100644 index 0000000000..a407e3ab70 --- /dev/null +++ b/Content.Server/UtkaIntegration/Commands/UtkaJobBanCommand.cs @@ -0,0 +1,22 @@ +using Content.Server.Administration.Managers; + +namespace Content.Server.UtkaIntegration; + +public sealed class UtkaJobBanCommand : IUtkaCommand +{ + public string Name => "jobban"; + public Type RequestMessageType => typeof(UtkaJobBanRequest); + public void Execute(UtkaTCPSession session, UtkaBaseMessage baseMessage) + { + if (baseMessage is not UtkaJobBanRequest message) return; + + var target = message.Ckey!; + var job = message.Type!; + var reason = message.Reason!; + var minutes = (uint) message.Duration!; + var isGlobalBan = (bool) message.Global!; + var admin = message.ACkey!; + + IoCManager.Resolve().UtkaCreateJobBan(admin, target, job, reason, minutes, isGlobalBan); + } +} diff --git a/Content.Server/UtkaIntegration/Commands/UtkaRestartRoundCommand.cs b/Content.Server/UtkaIntegration/Commands/UtkaRestartRoundCommand.cs new file mode 100644 index 0000000000..54a1a432a2 --- /dev/null +++ b/Content.Server/UtkaIntegration/Commands/UtkaRestartRoundCommand.cs @@ -0,0 +1,24 @@ +using Content.Server.GameTicking; + +namespace Content.Server.UtkaIntegration; + +public sealed class UtkaRestartRoundCommand : IUtkaCommand +{ + [Dependency] private UtkaTCPWrapper _utkaSocket = default!; + + public string Name => "restart_round"; + public Type RequestMessageType => typeof(UtkaRestartRoundRequest); + public void Execute(UtkaTCPSession session, UtkaBaseMessage baseMessage) + { + IoCManager.InjectDependencies(this); + + EntitySystem.Get().RestartRound(); + + var response = new UtkaRestartRoundResponse() + { + Restarted = true + }; + + _utkaSocket.SendMessageToAll(response); + } +} diff --git a/Content.Server/UtkaIntegration/Commands/UtkaWhoCommand.cs b/Content.Server/UtkaIntegration/Commands/UtkaWhoCommand.cs index a08cad435c..d3912e68a3 100644 --- a/Content.Server/UtkaIntegration/Commands/UtkaWhoCommand.cs +++ b/Content.Server/UtkaIntegration/Commands/UtkaWhoCommand.cs @@ -15,7 +15,7 @@ public sealed class UtkaWhoCommand : IUtkaCommand { public string Name => "who"; public Type RequestMessageType => typeof(UtkaWhoRequest); - + [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private UtkaTCPWrapper _utkaSocketWrapper = default!; diff --git a/Content.Server/UtkaIntegration/UtkaCommunication.cs b/Content.Server/UtkaIntegration/UtkaCommunication.cs index 24359d02a3..f84ab54cce 100644 --- a/Content.Server/UtkaIntegration/UtkaCommunication.cs +++ b/Content.Server/UtkaIntegration/UtkaCommunication.cs @@ -170,3 +170,137 @@ public sealed class UtkaChatMeEvent : UtkaBaseMessage [JsonPropertyName("character_name")] public string? CharacterName { get; set; } } + +public sealed class UtkaAhelpPmEvent : UtkaBaseMessage +{ + [JsonPropertyName("command")] + public override string? Command => "pm"; + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("ckey")] + public string? Ckey { get; set; } + + [JsonPropertyName("sender")] + public string? Sender { get; set; } +} + +public sealed class UtkaPlayerJoinedEvent : UtkaBaseMessage +{ + [JsonPropertyName("command")] + public override string? Command => "player_joined"; + + [JsonPropertyName("ckey")] + public string? Ckey { get; set; } +} + +public sealed class UtkaPlayerLeftEvent : UtkaBaseMessage +{ + [JsonPropertyName("command")] + public override string? Command => "player_left"; + + [JsonPropertyName("ckey")] + public string? Ckey { get; set; } +} + +public sealed class UtkaBannedEvent : UtkaBaseMessage +{ + [JsonPropertyName("command")] + public override string? Command => "banned"; + + [JsonPropertyName("ckey")] + public string? Ckey { get; set; } + + [JsonPropertyName("a_ckey")] + public string? ACkey { get; set; } + + [JsonPropertyName("bantype")] + public string? Bantype { get; set; } + + [JsonPropertyName("duration")] + public uint? Duration { get; set; } + + [JsonPropertyName("global")] + public bool? Global { get; set; } + + [JsonPropertyName("reason")] + public string? Reason { get; set; } +} + +public sealed class UtkaBanRequest : UtkaBaseMessage +{ + [JsonPropertyName("command")] + public override string? Command => "ban"; + + [JsonPropertyName("ckey")] + public string? Ckey { get; set; } + + [JsonPropertyName("a_ckey")] + public string? ACkey { get; set; } + + [JsonPropertyName("reason")] + public string? Reason { get; set; } + + [JsonPropertyName("duration")] + public uint? Duration { get; set; } + + [JsonPropertyName("global")] + public bool? Global { get; set; } +} + +public sealed class UtkaBanResponse : UtkaBaseMessage +{ + [JsonPropertyName("command")] + public override string? Command => "ban"; + + [JsonPropertyName("banned")] + public bool? Banned { get; set; } +} + +public sealed class UtkaJobBanRequest : UtkaBaseMessage +{ + public override string? Command => "jobban"; + + [JsonPropertyName("ckey")] + public string? Ckey { get; set; } + + [JsonPropertyName("a_ckey")] + public string? ACkey { get; set; } + + [JsonPropertyName("reason")] + public string? Reason { get; set; } + + [JsonPropertyName("duration")] + public uint? Duration { get; set; } + + [JsonPropertyName("global")] + public bool? Global { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } +} + +public sealed class UtkaJobBanResponse : UtkaBaseMessage +{ + [JsonPropertyName("command")] + public override string? Command => "jobban"; + + [JsonPropertyName("banned")] + public bool? Banned { get; set; } +} + +public sealed class UtkaRestartRoundRequest : UtkaBaseMessage +{ + [JsonPropertyName("command")] + public override string? Command => "restart_round"; +} + +public sealed class UtkaRestartRoundResponse : UtkaBaseMessage +{ + [JsonPropertyName("command")] + public override string? Command => "restart_round"; + + [JsonPropertyName("restarted")] + public bool? Restarted { get; set; } +}