From 2355f5e650b1b5b10c24f98de0244b5037aec6bc Mon Sep 17 00:00:00 2001 From: Myzumi <34660019+Myzumi@users.noreply.github.com> Date: Tue, 26 Nov 2024 22:13:56 +0100 Subject: [PATCH 01/11] Discord Ahelp Reply System (#2283) * First part of Remote Bwoinking * This should technically work * No Actoring * Fixes Guid not sending over * Making it work for the final. * Fixes for api * Moar Commants! Just more Comments to mark Frontier changes. * comment * wops * Fixes Naming Rules * I Should also fix the naming in the actually code... * Testing some new code * Naming rule and dependency fix (hopefully) * Serverside Webhook update on external sent ahelp messages * Still get data from custom URL's, even if it dosent match a discord webhook * Apply suggestions from code review (Part 1) Co-authored-by: Whatstone <166147148+whatston3@users.noreply.github.com> * Apply suggestions from code review (Part 2) * Bwoink system suggsetions * missing BwoinkSystem changes * Change access on BwoinkSystem._messageQueues * Updates the Regex to support other Discord Clients (beta, alpha) * Merge Fixes * BwoinkSystem: explicitly match "canary."/"ptb." --------- Co-authored-by: Whatstone <166147148+whatston3@users.noreply.github.com> Co-authored-by: Whatstone --- Content.Server/Administration/ServerApi.cs | 49 ++- .../Administration/Systems/BwoinkSystem.cs | 295 ++++++++++++++++-- Content.Server/Discord/WebhookPayload.cs | 2 + 3 files changed, 316 insertions(+), 30 deletions(-) diff --git a/Content.Server/Administration/ServerApi.cs b/Content.Server/Administration/ServerApi.cs index d0f23db637..5254d5ba77 100644 --- a/Content.Server/Administration/ServerApi.cs +++ b/Content.Server/Administration/ServerApi.cs @@ -7,12 +7,14 @@ using System.Text.Json.Nodes; using System.Threading.Tasks; using Content.Server.Administration.Systems; +using Content.Server.Administration.Managers; using Content.Server.GameTicking; using Content.Server.GameTicking.Presets; using Content.Server.GameTicking.Rules.Components; using Content.Server.Maps; using Content.Server.RoundEnd; using Content.Shared.Administration.Managers; +using Content.Shared.Administration; using Content.Shared.CCVar; using Content.Shared.GameTicking.Components; using Content.Shared.Prototypes; @@ -48,7 +50,7 @@ public sealed partial class ServerApi : IPostInjectInit [Dependency] private readonly IStatusHost _statusHost = default!; [Dependency] private readonly IConfigurationManager _config = default!; [Dependency] private readonly ISharedPlayerManager _playerManager = default!; - [Dependency] private readonly ISharedAdminManager _adminManager = default!; + [Dependency] private readonly IAdminManager _adminManager = default!; // Frontier: ISharedAdminManager [Dependency] private readonly IGameMapManager _gameMapManager = default!; [Dependency] private readonly IServerNetManager _netManager = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; @@ -81,6 +83,8 @@ void IPostInjectInit.PostInject() RegisterActorHandler(HttpMethod.Post, "/admin/actions/force_preset", ActionForcePreset); RegisterActorHandler(HttpMethod.Post, "/admin/actions/set_motd", ActionForceMotd); RegisterActorHandler(HttpMethod.Patch, "/admin/actions/panic_bunker", ActionPanicPunker); + + RegisterHandler(HttpMethod.Post, "/admin/actions/send_bwoink", ActionSendBwoink); // Frontier - Discord Ahelp Reply } public void Initialize() @@ -393,6 +397,40 @@ await RunOnMainThread(async () => _sawmill.Info($"Forced instant round restart by {FormatLogActor(actor)}"); await RespondOk(context); }); + } + #endregion + + #region Frontier + // Creating a region here incase more actions are added in the future + + private async Task ActionSendBwoink(IStatusHandlerContext context) + { + var body = await ReadJson(context); + if (body == null) + return; + + await RunOnMainThread(async () => + { + // Player not online or wrong Guid + if (!_playerManager.TryGetSessionById(new NetUserId(body.Guid), out var player)) + { + await RespondError( + context, + ErrorCode.PlayerNotFound, + HttpStatusCode.UnprocessableContent, + "Player not found"); + return; + } + + var serverBwoinkSystem = _entitySystemManager.GetEntitySystem(); + var message = new SharedBwoinkSystem.BwoinkTextMessage(player.UserId, SharedBwoinkSystem.SystemUserId, body.Text); + serverBwoinkSystem.OnWebhookBwoinkTextMessage(message, body); + + // Respond with OK + await RespondOk(context); + }); + + } #endregion @@ -631,6 +669,15 @@ private sealed class MotdActionBody public required string Motd { get; init; } } + public sealed class BwoinkActionBody + { + public required string Text { get; init; } + public required string Username { get; init; } + public required Guid Guid { get; init; } + public bool UserOnly { get; init; } + public required bool WebhookUpdate { get; init; } + } + #endregion #region Responses diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index a07115544b..4bc5a63255 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -36,7 +36,7 @@ public sealed partial class BwoinkSystem : SharedBwoinkSystem [Dependency] private readonly SharedMindSystem _minds = default!; [Dependency] private readonly IAfkManager _afkManager = default!; - [GeneratedRegex(@"^https://discord\.com/api/webhooks/(\d+)/((?!.*/).*)$")] + [GeneratedRegex(@"^https://(?:(?:canary|ptb)\.)?discord\.com/api/webhooks/(\d+)/((?!.*/).*)$")] private static partial Regex DiscordRegex(); private ISawmill _sawmill = default!; @@ -80,6 +80,48 @@ public override void Initialize() SubscribeLocalEvent(OnGameRunLevelChanged); SubscribeNetworkEvent(OnClientTypingUpdated); + SubscribeLocalEvent(_ => _activeConversations.Clear()); + + _rateLimit.Register( + RateLimitKey, + new RateLimitRegistration(CCVars.AhelpRateLimitPeriod, + CCVars.AhelpRateLimitCount, + PlayerRateLimitedAction) + ); + } + + private async void OnCallChanged(string url) + { + _onCallUrl = url; + + if (url == string.Empty) + return; + + var match = DiscordRegex().Match(url); + + if (!match.Success) + { + Log.Error("On call URL does not appear to be valid."); + return; + } + + if (match.Groups.Count <= 2) + { + Log.Error("Could not get webhook ID or token for on call URL."); + return; + } + + var webhookId = match.Groups[1].Value; + var webhookToken = match.Groups[2].Value; + + _onCallData = await GetWebhookData(url); + } + + private void PlayerRateLimitedAction(ICommonSession obj) + { + RaiseNetworkEvent( + new BwoinkTextMessage(obj.UserId, default, Loc.GetString("bwoink-system-rate-limited"), playSound: false), + obj.Channel); } private void OnOverrideChanged(string obj) @@ -166,6 +208,7 @@ private async void OnWebhookChanged(string url) { // TODO: Ideally, CVar validation during setting should be better integrated Log.Warning("Webhook URL does not appear to be valid. Using anyways..."); + await GetWebhookData(url); // Frontier - Support for Custom URLS, we still want to see if theres Webhook data available return; } @@ -175,22 +218,20 @@ private async void OnWebhookChanged(string url) return; } - var webhookId = match.Groups[1].Value; - var webhookToken = match.Groups[2].Value; - // Fire and forget - await SetWebhookData(webhookId, webhookToken); + await GetWebhookData(url); // Frontier - Support for Custom URLS } - private async Task SetWebhookData(string id, string token) + private async Task GetWebhookData(string url) { - var response = await _httpClient.GetAsync($"https://discord.com/api/v10/webhooks/{id}/{token}"); + var response = await _httpClient.GetAsync(url); var content = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { - _sawmill.Log(LogLevel.Error, $"Discord returned bad status code when trying to get webhook data (perhaps the webhook URL is invalid?): {response.StatusCode}\nResponse: {content}"); - return; + _sawmill.Log(LogLevel.Error, + $"Webhook returned bad status code when trying to get webhook data (perhaps the webhook URL is invalid?): {response.StatusCode}\nResponse: {content}"); + return null; } _webhookData = JsonSerializer.Deserialize(content); @@ -270,7 +311,10 @@ private async void ProcessQueue(NetUserId userId, Queue messages) existingEmbed.description += $"\n{message}"; } - var payload = GeneratePayload(existingEmbed.description, existingEmbed.username, existingEmbed.characterName); + var payload = GeneratePayload(existingEmbed.Description, + existingEmbed.Username, + userId.UserId, // Frontier, this is used to identify the players in the webhook + existingEmbed.CharacterName); // If there is no existing embed, create a new one // Otherwise patch (edit) it @@ -313,10 +357,47 @@ private async void ProcessQueue(NetUserId userId, Queue messages) _relayMessages[userId] = existingEmbed; + // Actually do the on call relay last, we just need to grab it before we dequeue every message above. + if (onCallRelay && + _onCallData != null) + { + existingEmbed.OnCall = true; + var roleMention = _config.GetCVar(CCVars.DiscordAhelpMention); + + if (!string.IsNullOrEmpty(roleMention)) + { + var message = new StringBuilder(); + message.AppendLine($"<@&{roleMention}>"); + message.AppendLine("Unanswered SOS"); + + // Need webhook data to get the correct link for that channel rather than on-call data. + if (_webhookData is { GuildId: { } guildId, ChannelId: { } channelId }) + { + message.AppendLine( + $"**[Go to ahelp](https://discord.com/channels/{guildId}/{channelId}/{existingEmbed.Id})**"); + } + + payload = GeneratePayload(message.ToString(), existingEmbed.Username, userId, existingEmbed.CharacterName); + + var request = await _httpClient.PostAsync($"{_onCallUrl}?wait=true", + new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); + + var content = await request.Content.ReadAsStringAsync(); + if (!request.IsSuccessStatusCode) + { + _sawmill.Log(LogLevel.Error, $"Discord returned bad status code when posting relay message (perhaps the message is too long?): {request.StatusCode}\nResponse: {content}"); + } + } + } + else + { + existingEmbed.OnCall = false; + } + _processingChannels.Remove(userId); } - private WebhookPayload GeneratePayload(string messages, string username, string? characterName = null) + private WebhookPayload GeneratePayload(string messages, string username, Guid userId, string? characterName = null) // Frontier: added Guid { // Add character name if (characterName != null) @@ -341,6 +422,7 @@ private WebhookPayload GeneratePayload(string messages, string username, string? return new WebhookPayload { Username = username, + UserID = userId, // Frontier, this is used to identify the players in the webhook AvatarUrl = string.IsNullOrWhiteSpace(_avatarUrl) ? null : _avatarUrl, Embeds = new List { @@ -378,9 +460,20 @@ public override void Update(float frameTime) } } + // Frontier: webhook text messages + public void OnWebhookBwoinkTextMessage(BwoinkTextMessage message, ServerApi.BwoinkActionBody body) + { + // Note for forks: + AdminData webhookAdminData = new(); + + // TODO: fix args + OnBwoinkInternal(message, SystemUserId, webhookAdminData, body.Username, null, body.UserOnly, body.WebhookUpdate, true); + } + protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySessionEventArgs eventArgs) { base.OnBwoinkTextMessage(message, eventArgs); + var senderSession = eventArgs.SenderSession; // TODO: Sanitize text? @@ -395,28 +488,51 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes return; } + if (_rateLimit.CountAction(eventArgs.SenderSession, RateLimitKey) != RateLimitStatus.Allowed) + return; + + OnBwoinkInternal(message, eventArgs.SenderSession.UserId, senderAdmin, eventArgs.SenderSession.Name, eventArgs.SenderSession.Channel, false, true, false); + } + + /// + /// Sends a bwoink. Common to both internal messages (sent via the ahelp or admin interface) and webhook messages (sent through the webhook, e.g. via Discord) + /// + /// The message being sent. + /// The network GUID of the person sending the message. + /// The admin privileges of the person sending the message. + /// The name of the person sending the message. + /// The channel to send a message to, e.g. in case of failure to send + /// If true, message should be sent off through the webhook if possible + /// Message originated from a webhook (e.g. Discord) + private void OnBwoinkInternal(BwoinkTextMessage message, NetUserId senderId, AdminData? senderAdmin, string senderName, INetChannel? senderChannel, bool userOnly, bool sendWebhook, bool fromWebhook) + { + _activeConversations[message.UserId] = DateTime.Now; + var escapedText = FormattedMessage.EscapeText(message.Text); string bwoinkText; if (senderAdmin is not null && senderAdmin.Flags == AdminFlags.Adminhelp) // Mentor. Not full admin. That's why it's colored differently. { - bwoinkText = $"[color=purple]{senderSession.Name}[/color]"; + bwoinkText = $"[color=purple]{adminPrefix}{senderName}[/color]"; } - else if (senderAdmin is not null && senderAdmin.HasFlag(AdminFlags.Adminhelp)) + else if (fromWebhook || senderAdmin is not null && senderAdmin.HasFlag(AdminFlags.Adminhelp)) // Frontier: anything sent via webhooks are from an admin. { - bwoinkText = $"[color=red]{senderSession.Name}[/color]"; + bwoinkText = $"[color=red]{adminPrefix}{senderName}[/color]"; } else { - bwoinkText = $"{senderSession.Name}"; + bwoinkText = $"{senderName}"; } + if (fromWebhook) + bwoinkText = $"(DC) {bwoinkText}"; + bwoinkText = $"{(message.PlaySound ? "" : "(S) ")}{bwoinkText}: {escapedText}"; // If it's not an admin / admin chooses to keep the sound then play it. - var playSound = !senderAHelpAdmin || message.PlaySound; - var msg = new BwoinkTextMessage(message.UserId, senderSession.UserId, bwoinkText, playSound: playSound); + var playSound = senderAdmin == null || message.PlaySound; + var msg = new BwoinkTextMessage(message.UserId, senderId, bwoinkText, playSound: playSound); LogBwoink(msg); @@ -428,6 +544,13 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes RaiseNetworkEvent(msg, channel); } + string adminPrefixWebhook = ""; + + if (_config.GetCVar(CCVars.AhelpAdminPrefixWebhook) && senderAdmin is not null && senderAdmin.Title is not null) + { + adminPrefixWebhook = $"[bold]\\[{senderAdmin.Title}\\][/bold] "; + } + // Notify player if (_playerManager.TryGetSessionById(message.UserId, out var session)) { @@ -448,12 +571,19 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes } else { - overrideMsgText = $"{senderSession.Name}"; // Not an admin, name is not overridden. + overrideMsgText = $"{senderName}"; // Not an admin, name is not overridden. } + if (fromWebhook) + overrideMsgText = $"(DC) {overrideMsgText}"; + overrideMsgText = $"{(message.PlaySound ? "" : "(S) ")}{overrideMsgText}: {escapedText}"; - RaiseNetworkEvent(new BwoinkTextMessage(message.UserId, senderSession.UserId, overrideMsgText, playSound: playSound), session.Channel); + RaiseNetworkEvent(new BwoinkTextMessage(message.UserId, + senderId, + overrideMsgText, + playSound: playSound), + session.Channel); } else RaiseNetworkEvent(msg, session.Channel); @@ -461,30 +591,44 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes } var sendsWebhook = _webhookUrl != string.Empty; - if (sendsWebhook) + if (sendsWebhook && sendWebhook) { if (!_messageQueues.ContainsKey(msg.UserId)) _messageQueues[msg.UserId] = new Queue(); var str = message.Text; - var unameLength = senderSession.Name.Length; + var unameLength = senderName.Length; if (unameLength + str.Length + _maxAdditionalChars > DescriptionMax) { str = str[..(DescriptionMax - _maxAdditionalChars - unameLength)]; } var nonAfkAdmins = GetNonAfkAdmins(); - _messageQueues[msg.UserId].Enqueue(GenerateAHelpMessage(senderSession.Name, str, !personalChannel, _gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"), _gameTicker.RunLevel, playedSound: playSound, noReceivers: nonAfkAdmins.Count == 0)); + var messageParams = new AHelpMessageParams( + senderName, + str, + senderId != message.UserId, + _gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"), + _gameTicker.RunLevel, + playedSound: playSound, + isDiscord: fromWebhook, + noReceivers: nonAfkAdmins.Count == 0 + ); + _messageQueues[msg.UserId].Enqueue(GenerateAHelpMessage(messageParams)); } if (admins.Count != 0 || sendsWebhook) return; // No admin online, let the player know - var systemText = Loc.GetString("bwoink-system-starmute-message-no-other-users"); - var starMuteMsg = new BwoinkTextMessage(message.UserId, SystemUserId, systemText); - RaiseNetworkEvent(starMuteMsg, senderSession.Channel); + if (senderChannel != null) + { + var systemText = Loc.GetString("bwoink-system-starmute-message-no-other-users"); + var starMuteMsg = new BwoinkTextMessage(message.UserId, SystemUserId, systemText); + RaiseNetworkEvent(starMuteMsg, senderChannel); + } } + // End Frontier: private IList GetNonAfkAdmins() { @@ -517,10 +661,103 @@ private static string GenerateAHelpMessage(string username, string message, bool stringbuilder.Append($" **{roundTime}**"); if (!playedSound) stringbuilder.Append(" **(S)**"); - stringbuilder.Append($" **{username}:** "); - stringbuilder.Append(message); - return stringbuilder.ToString(); + + if (parameters.IsDiscord) // Frontier - Discord Indicator + stringbuilder.Append(" **(DC)**"); + + if (parameters.Icon == null) + stringbuilder.Append($" **{parameters.Username}:** "); + else + stringbuilder.Append($" **{parameters.Username}** "); + stringbuilder.Append(parameters.Message); + + return new DiscordRelayedData() + { + Receivers = !parameters.NoReceivers, + Message = stringbuilder.ToString(), + }; + } + + private record struct DiscordRelayedData + { + /// + /// Was anyone online to receive it. + /// + public bool Receivers; + + /// + /// What's the payload to send to discord. + /// + public string Message; + } + + /// + /// Class specifically for holding information regarding existing Discord embeds + /// + private sealed class DiscordRelayInteraction + { + public string? Id; + + public string Username = String.Empty; + + public string? CharacterName; + + /// + /// Contents for the discord message. + /// + public string Description = string.Empty; + + /// + /// Run level of the last interaction. If different we'll link to the last Id. + /// + public GameRunLevel LastRunLevel; + + /// + /// Did we relay this interaction to OnCall previously. + /// + public bool OnCall; } } -} + public sealed class AHelpMessageParams + { + public string Username { get; set; } + public string Message { get; set; } + public bool IsAdmin { get; set; } + public string RoundTime { get; set; } + public GameRunLevel RoundState { get; set; } + public bool PlayedSound { get; set; } + public bool NoReceivers { get; set; } + public bool IsDiscord { get; set; } // Frontier + public string? Icon { get; set; } + + public AHelpMessageParams( + string username, + string message, + bool isAdmin, + string roundTime, + GameRunLevel roundState, + bool playedSound, + bool isDiscord = false, // Frontier + bool noReceivers = false, + string? icon = null) + { + Username = username; + Message = message; + IsAdmin = isAdmin; + RoundTime = roundTime; + RoundState = roundState; + IsDiscord = isDiscord; // Frontier + PlayedSound = playedSound; + NoReceivers = noReceivers; + Icon = icon; + } + } + + public enum PlayerStatusType + { + Connected, + Disconnected, + Banned, + } +} diff --git a/Content.Server/Discord/WebhookPayload.cs b/Content.Server/Discord/WebhookPayload.cs index fdf5f48444..8d587e0bd1 100644 --- a/Content.Server/Discord/WebhookPayload.cs +++ b/Content.Server/Discord/WebhookPayload.cs @@ -5,6 +5,8 @@ namespace Content.Server.Discord; // https://discord.com/developers/docs/resources/channel#message-object-message-structure public struct WebhookPayload { + [JsonPropertyName("UserID")] // Frontier, this is used to identify the players in the webhook + public Guid? UserID { get; set; } /// /// The message to send in the webhook. Maximum of 2000 characters. /// From 236d1e42a1ab7b96feefa0f0f9d4c20075037d59 Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Fri, 21 Jun 2024 00:13:02 +0200 Subject: [PATCH 02/11] Rate limit ahelps (#29219) * Make chat rate limits a general-purpose system. Intending to use this with ahelps next. * Rate limt ahelps Fixes #28762 * Review comments --- .../Administration/Systems/BwoinkSystem.cs | 4 + .../Chat/Managers/ChatManager.RateLimit.cs | 85 ++---- Content.Server/Chat/Managers/ChatManager.cs | 10 +- Content.Server/Chat/Managers/IChatManager.cs | 4 +- Content.Server/Chat/Systems/ChatSystem.cs | 5 +- Content.Server/Entry/EntryPoint.cs | 4 + Content.Server/IoC/ServerContentIoC.cs | 6 +- .../RateLimiting/PlayerRateLimitManager.cs | 254 ++++++++++++++++++ Content.Shared.Database/LogType.cs | 9 +- Content.Shared/CCVar/CCVars.cs | 19 ++ .../Locale/en-US/administration/bwoink.ftl | 4 + 11 files changed, 329 insertions(+), 75 deletions(-) create mode 100644 Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index 4bc5a63255..0c2a318666 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -9,6 +9,7 @@ using Content.Server.Afk; using Content.Server.Discord; using Content.Server.GameTicking; +using Content.Server.Players.RateLimiting; using Content.Shared.Administration; using Content.Shared.CCVar; using Content.Shared.Mind; @@ -27,6 +28,8 @@ namespace Content.Server.Administration.Systems [UsedImplicitly] public sealed partial class BwoinkSystem : SharedBwoinkSystem { + private const string RateLimitKey = "AdminHelp"; + [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IAdminManager _adminManager = default!; [Dependency] private readonly IConfigurationManager _config = default!; @@ -35,6 +38,7 @@ public sealed partial class BwoinkSystem : SharedBwoinkSystem [Dependency] private readonly GameTicker _gameTicker = default!; [Dependency] private readonly SharedMindSystem _minds = default!; [Dependency] private readonly IAfkManager _afkManager = default!; + [Dependency] private readonly PlayerRateLimitManager _rateLimit = default!; [GeneratedRegex(@"^https://(?:(?:canary|ptb)\.)?discord\.com/api/webhooks/(\d+)/((?!.*/).*)$")] private static partial Regex DiscordRegex(); diff --git a/Content.Server/Chat/Managers/ChatManager.RateLimit.cs b/Content.Server/Chat/Managers/ChatManager.RateLimit.cs index cf87ab6322..45e7d2e20d 100644 --- a/Content.Server/Chat/Managers/ChatManager.RateLimit.cs +++ b/Content.Server/Chat/Managers/ChatManager.RateLimit.cs @@ -1,84 +1,41 @@ -using System.Runtime.InteropServices; +using Content.Server.Players.RateLimiting; using Content.Shared.CCVar; using Content.Shared.Database; -using Robust.Shared.Enums; using Robust.Shared.Player; -using Robust.Shared.Timing; namespace Content.Server.Chat.Managers; internal sealed partial class ChatManager { - private readonly Dictionary _rateLimitData = new(); + private const string RateLimitKey = "Chat"; - public bool HandleRateLimit(ICommonSession player) + private void RegisterRateLimits() { - ref var datum = ref CollectionsMarshal.GetValueRefOrAddDefault(_rateLimitData, player, out _); - var time = _gameTiming.RealTime; - if (datum.CountExpires < time) - { - // Period expired, reset it. - var periodLength = _configurationManager.GetCVar(CCVars.ChatRateLimitPeriod); - datum.CountExpires = time + TimeSpan.FromSeconds(periodLength); - datum.Count = 0; - datum.Announced = false; - } - - var maxCount = _configurationManager.GetCVar(CCVars.ChatRateLimitCount); - datum.Count += 1; - - if (datum.Count <= maxCount) - return true; - - // Breached rate limits, inform admins if configured. - if (_configurationManager.GetCVar(CCVars.ChatRateLimitAnnounceAdmins)) - { - if (datum.NextAdminAnnounce < time) + _rateLimitManager.Register(RateLimitKey, + new RateLimitRegistration { - SendAdminAlert(Loc.GetString("chat-manager-rate-limit-admin-announcement", ("player", player.Name))); - var delay = _configurationManager.GetCVar(CCVars.ChatRateLimitAnnounceAdminsDelay); - datum.NextAdminAnnounce = time + TimeSpan.FromSeconds(delay); - } - } - - if (!datum.Announced) - { - DispatchServerMessage(player, Loc.GetString("chat-manager-rate-limited"), suppressLog: true); - _adminLogger.Add(LogType.ChatRateLimited, LogImpact.Medium, $"Player {player} breached chat rate limits"); - - datum.Announced = true; - } - - return false; + CVarLimitPeriodLength = CCVars.ChatRateLimitPeriod, + CVarLimitCount = CCVars.ChatRateLimitCount, + CVarAdminAnnounceDelay = CCVars.ChatRateLimitAnnounceAdminsDelay, + PlayerLimitedAction = RateLimitPlayerLimited, + AdminAnnounceAction = RateLimitAlertAdmins, + AdminLogType = LogType.ChatRateLimited, + }); } - private void PlayerStatusChanged(object? sender, SessionStatusEventArgs e) + private void RateLimitPlayerLimited(ICommonSession player) { - if (e.NewStatus == SessionStatus.Disconnected) - _rateLimitData.Remove(e.Session); + DispatchServerMessage(player, Loc.GetString("chat-manager-rate-limited"), suppressLog: true); } - private struct RateLimitDatum + private void RateLimitAlertAdmins(ICommonSession player) { - /// - /// Time stamp (relative to ) this rate limit period will expire at. - /// - public TimeSpan CountExpires; - - /// - /// How many messages have been sent in the current rate limit period. - /// - public int Count; - - /// - /// Have we announced to the player that they've been blocked in this rate limit period? - /// - public bool Announced; + if (_configurationManager.GetCVar(CCVars.ChatRateLimitAnnounceAdmins)) + SendAdminAlert(Loc.GetString("chat-manager-rate-limit-admin-announcement", ("player", player.Name))); + } - /// - /// Time stamp (relative to ) of the - /// next time we can send an announcement to admins about rate limit breach. - /// - public TimeSpan NextAdminAnnounce; + public RateLimitStatus HandleRateLimit(ICommonSession player) + { + return _rateLimitManager.CountAction(player, RateLimitKey); } } diff --git a/Content.Server/Chat/Managers/ChatManager.cs b/Content.Server/Chat/Managers/ChatManager.cs index 812aed80bd..b80daa2c27 100644 --- a/Content.Server/Chat/Managers/ChatManager.cs +++ b/Content.Server/Chat/Managers/ChatManager.cs @@ -5,18 +5,17 @@ using Content.Server.Administration.Managers; using Content.Server.Administration.Systems; using Content.Server.MoMMI; +using Content.Server.Players.RateLimiting; using Content.Server.Preferences.Managers; using Content.Shared.Administration; using Content.Shared.CCVar; using Content.Shared.Chat; using Content.Shared.Database; using Content.Shared.Mind; -using Robust.Server.Player; using Robust.Shared.Configuration; using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Replays; -using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Content.Server.Chat.Managers @@ -43,8 +42,7 @@ internal sealed partial class ChatManager : IChatManager [Dependency] private readonly IConfigurationManager _configurationManager = default!; [Dependency] private readonly INetConfigurationManager _netConfigManager = default!; [Dependency] private readonly IEntityManager _entityManager = default!; - [Dependency] private readonly IGameTiming _gameTiming = default!; - [Dependency] private readonly IPlayerManager _playerManager = default!; + [Dependency] private readonly PlayerRateLimitManager _rateLimitManager = default!; /// /// The maximum length a player-sent message can be sent @@ -64,7 +62,7 @@ public void Initialize() _configurationManager.OnValueChanged(CCVars.OocEnabled, OnOocEnabledChanged, true); _configurationManager.OnValueChanged(CCVars.AdminOocEnabled, OnAdminOocEnabledChanged, true); - _playerManager.PlayerStatusChanged += PlayerStatusChanged; + RegisterRateLimits(); } private void OnOocEnabledChanged(bool val) @@ -198,7 +196,7 @@ public void SendHookOOC(string sender, string message) /// The type of message. public void TrySendOOCMessage(ICommonSession player, string message, OOCChatType type) { - if (!HandleRateLimit(player)) + if (HandleRateLimit(player) != RateLimitStatus.Allowed) return; // Check if message exceeds the character limit diff --git a/Content.Server/Chat/Managers/IChatManager.cs b/Content.Server/Chat/Managers/IChatManager.cs index 59945bf5ca..35059a254e 100644 --- a/Content.Server/Chat/Managers/IChatManager.cs +++ b/Content.Server/Chat/Managers/IChatManager.cs @@ -1,4 +1,6 @@ using System.Diagnostics.CodeAnalysis; +using Content.Server.Players; +using Content.Server.Players.RateLimiting; using Content.Shared.Administration; using Content.Shared.Chat; using Robust.Shared.Network; @@ -49,6 +51,6 @@ void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessag /// /// The player sending a chat message. /// False if the player has violated rate limits and should be blocked from sending further messages. - bool HandleRateLimit(ICommonSession player); + RateLimitStatus HandleRateLimit(ICommonSession player); } } diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs index 8f8991de1b..bfc22f2ad2 100644 --- a/Content.Server/Chat/Systems/ChatSystem.cs +++ b/Content.Server/Chat/Systems/ChatSystem.cs @@ -5,6 +5,7 @@ using Content.Server.Administration.Managers; using Content.Server.Chat.Managers; using Content.Server.GameTicking; +using Content.Server.Players.RateLimiting; using Content.Server.Language; using Content.Server.Speech.Components; using Content.Server.Speech.EntitySystems; @@ -192,7 +193,7 @@ public void TrySendInGameICMessage( return; } - if (player != null && !_chatManager.HandleRateLimit(player)) + if (player != null && _chatManager.HandleRateLimit(player) != RateLimitStatus.Allowed) return; // Sus @@ -294,7 +295,7 @@ public void TrySendInGameOOCMessage( if (!CanSendInGame(message, shell, player)) return; - if (player != null && !_chatManager.HandleRateLimit(player)) + if (player != null && _chatManager.HandleRateLimit(player) != RateLimitStatus.Allowed) return; // It doesn't make any sense for a non-player to send in-game OOC messages, whereas non-players may be sending diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs index 206f0a6b9d..52b4575aad 100644 --- a/Content.Server/Entry/EntryPoint.cs +++ b/Content.Server/Entry/EntryPoint.cs @@ -17,7 +17,10 @@ using Content.Server.Players.JobWhitelist; using Content.Server.Maps; using Content.Server.NodeContainer.NodeGroups; +using Content.Server.Players; +using Content.Server.Players.JobWhitelist; using Content.Server.Players.PlayTimeTracking; +using Content.Server.Players.RateLimiting; using Content.Server.Preferences.Managers; using Content.Server.ServerInfo; using Content.Server.ServerUpdates; @@ -114,6 +117,7 @@ public override void Init() _updateManager.Initialize(); _playTimeTracking.Initialize(); IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); } } diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index 80cac6fed1..842a8e7276 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -16,7 +16,10 @@ using Content.Server.Players.JobWhitelist; using Content.Server.MoMMI; using Content.Server.NodeContainer.NodeGroups; +using Content.Server.Players; +using Content.Server.Players.JobWhitelist; using Content.Server.Players.PlayTimeTracking; +using Content.Server.Players.RateLimiting; using Content.Server.Preferences.Managers; using Content.Server.ServerInfo; using Content.Server.ServerUpdates; @@ -62,11 +65,12 @@ public static void Register() IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); - IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs b/Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs new file mode 100644 index 0000000000..59f086f9c3 --- /dev/null +++ b/Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs @@ -0,0 +1,254 @@ +using System.Runtime.InteropServices; +using Content.Server.Administration.Logs; +using Content.Shared.Database; +using Robust.Server.Player; +using Robust.Shared.Configuration; +using Robust.Shared.Enums; +using Robust.Shared.Player; +using Robust.Shared.Timing; +using Robust.Shared.Utility; + +namespace Content.Server.Players.RateLimiting; + +/// +/// General-purpose system to rate limit actions taken by clients, such as chat messages. +/// +/// +/// +/// Different categories of rate limits must be registered ahead of time by calling . +/// Once registered, you can simply call to count a rate-limited action for a player. +/// +/// +/// This system is intended for rate limiting player actions over short periods, +/// to ward against spam that can cause technical issues such as admin client load. +/// It should not be used for in-game actions or similar. +/// +/// +/// Rate limits are reset when a client reconnects. +/// This should not be an issue for the reasonably short rate limit periods this system is intended for. +/// +/// +/// +public sealed class PlayerRateLimitManager +{ + [Dependency] private readonly IAdminLogManager _adminLog = default!; + [Dependency] private readonly IGameTiming _gameTiming = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IPlayerManager _playerManager = default!; + + private readonly Dictionary _registrations = new(); + private readonly Dictionary> _rateLimitData = new(); + + /// + /// Count and validate an action performed by a player against rate limits. + /// + /// The player performing the action. + /// The key string that was previously used to register a rate limit category. + /// Whether the action counted should be blocked due to surpassing rate limits or not. + /// + /// is not a connected player + /// OR is not a registered rate limit category. + /// + /// + public RateLimitStatus CountAction(ICommonSession player, string key) + { + if (player.Status == SessionStatus.Disconnected) + throw new ArgumentException("Player is not connected"); + if (!_registrations.TryGetValue(key, out var registration)) + throw new ArgumentException($"Unregistered key: {key}"); + + var playerData = _rateLimitData.GetOrNew(player); + ref var datum = ref CollectionsMarshal.GetValueRefOrAddDefault(playerData, key, out _); + var time = _gameTiming.RealTime; + if (datum.CountExpires < time) + { + // Period expired, reset it. + datum.CountExpires = time + registration.LimitPeriod; + datum.Count = 0; + datum.Announced = false; + } + + datum.Count += 1; + + if (datum.Count <= registration.LimitCount) + return RateLimitStatus.Allowed; + + // Breached rate limits, inform admins if configured. + if (registration.AdminAnnounceDelay is { } cvarAnnounceDelay) + { + if (datum.NextAdminAnnounce < time) + { + registration.Registration.AdminAnnounceAction!(player); + datum.NextAdminAnnounce = time + cvarAnnounceDelay; + } + } + + if (!datum.Announced) + { + registration.Registration.PlayerLimitedAction(player); + _adminLog.Add( + registration.Registration.AdminLogType, + LogImpact.Medium, + $"Player {player} breached '{key}' rate limit "); + + datum.Announced = true; + } + + return RateLimitStatus.Blocked; + } + + /// + /// Register a new rate limit category. + /// + /// + /// The key string that will be referred to later with . + /// Must be unique and should probably just be a constant somewhere. + /// + /// The data specifying the rate limit's parameters. + /// has already been registered. + /// is invalid. + public void Register(string key, RateLimitRegistration registration) + { + if (_registrations.ContainsKey(key)) + throw new InvalidOperationException($"Key already registered: {key}"); + + var data = new RegistrationData + { + Registration = registration, + }; + + if ((registration.AdminAnnounceAction == null) != (registration.CVarAdminAnnounceDelay == null)) + { + throw new ArgumentException( + $"Must set either both {nameof(registration.AdminAnnounceAction)} and {nameof(registration.CVarAdminAnnounceDelay)} or neither"); + } + + _cfg.OnValueChanged( + registration.CVarLimitCount, + i => data.LimitCount = i, + invokeImmediately: true); + _cfg.OnValueChanged( + registration.CVarLimitPeriodLength, + i => data.LimitPeriod = TimeSpan.FromSeconds(i), + invokeImmediately: true); + + if (registration.CVarAdminAnnounceDelay != null) + { + _cfg.OnValueChanged( + registration.CVarLimitCount, + i => data.AdminAnnounceDelay = TimeSpan.FromSeconds(i), + invokeImmediately: true); + } + + _registrations.Add(key, data); + } + + /// + /// Initialize the manager's functionality at game startup. + /// + public void Initialize() + { + _playerManager.PlayerStatusChanged += PlayerManagerOnPlayerStatusChanged; + } + + private void PlayerManagerOnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) + { + if (e.NewStatus == SessionStatus.Disconnected) + _rateLimitData.Remove(e.Session); + } + + private sealed class RegistrationData + { + public required RateLimitRegistration Registration { get; init; } + public TimeSpan LimitPeriod { get; set; } + public int LimitCount { get; set; } + public TimeSpan? AdminAnnounceDelay { get; set; } + } + + private struct RateLimitDatum + { + /// + /// Time stamp (relative to ) this rate limit period will expire at. + /// + public TimeSpan CountExpires; + + /// + /// How many actions have been done in the current rate limit period. + /// + public int Count; + + /// + /// Have we announced to the player that they've been blocked in this rate limit period? + /// + public bool Announced; + + /// + /// Time stamp (relative to ) of the + /// next time we can send an announcement to admins about rate limit breach. + /// + public TimeSpan NextAdminAnnounce; + } +} + +/// +/// Contains all data necessary to register a rate limit with . +/// +public sealed class RateLimitRegistration +{ + /// + /// CVar that controls the period over which the rate limit is counted, measured in seconds. + /// + public required CVarDef CVarLimitPeriodLength { get; init; } + + /// + /// CVar that controls how many actions are allowed in a single rate limit period. + /// + public required CVarDef CVarLimitCount { get; init; } + + /// + /// An action that gets invoked when this rate limit has been breached by a player. + /// + /// + /// This can be used for informing players or taking administrative action. + /// + public required Action PlayerLimitedAction { get; init; } + + /// + /// CVar that controls the minimum delay between admin notifications, measured in seconds. + /// This can be omitted to have no admin notification system. + /// + /// + /// If set, must be set too. + /// + public CVarDef? CVarAdminAnnounceDelay { get; init; } + + /// + /// An action that gets invoked when a rate limit was breached and admins should be notified. + /// + /// + /// If set, must be set too. + /// + public Action? AdminAnnounceAction { get; init; } + + /// + /// Log type used to log rate limit violations to the admin logs system. + /// + public LogType AdminLogType { get; init; } = LogType.RateLimited; +} + +/// +/// Result of a rate-limited operation. +/// +/// +public enum RateLimitStatus : byte +{ + /// + /// The action was not blocked by the rate limit. + /// + Allowed, + + /// + /// The action was blocked by the rate limit. + /// + Blocked, +} diff --git a/Content.Shared.Database/LogType.cs b/Content.Shared.Database/LogType.cs index 6d84808588..4edd93b5e0 100644 --- a/Content.Shared.Database/LogType.cs +++ b/Content.Shared.Database/LogType.cs @@ -99,5 +99,12 @@ public enum LogType AtmosTemperatureChanged = 88, DeviceNetwork = 89, StoreRefund = 90, - Consent = 12489, // random high number to avoid conflict when upstream adds new log types + /// + /// User was rate-limited for some spam action. + /// + /// + /// This is a default value used by PlayerRateLimitManager, though users can use different log types. + /// + RateLimited = 91, + Consent = 12489, // random high number to avoid conflict when upstream adds new log types, } diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index 53ea99776e..b5479567e7 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -950,6 +950,25 @@ public static readonly CVarDef public static readonly CVarDef AdminAfkTime = CVarDef.Create("admin.afk_time", 600f, CVar.SERVERONLY); + /* + * AHELP + */ + + /// + /// Ahelp rate limit values are accounted in periods of this size (seconds). + /// After the period has passed, the count resets. + /// + /// + public static readonly CVarDef AhelpRateLimitPeriod = + CVarDef.Create("ahelp.rate_limit_period", 2, CVar.SERVERONLY); + + /// + /// How many ahelp messages are allowed in a single rate limit period. + /// + /// + public static readonly CVarDef AhelpRateLimitCount = + CVarDef.Create("ahelp.rate_limit_count", 10, CVar.SERVERONLY); + /* * Explosions */ diff --git a/Resources/Locale/en-US/administration/bwoink.ftl b/Resources/Locale/en-US/administration/bwoink.ftl index 94d3328bde..3a92f58ad1 100644 --- a/Resources/Locale/en-US/administration/bwoink.ftl +++ b/Resources/Locale/en-US/administration/bwoink.ftl @@ -12,3 +12,7 @@ bwoink-system-typing-indicator = {$players} {$count -> } typing... admin-bwoink-play-sound = Bwoink? + +bwoink-title-none-selected = None selected + +bwoink-system-rate-limited = System: you are sending messages too quickly. From 107d87b29c9968ec1e2f1870a69a66a0d4d07252 Mon Sep 17 00:00:00 2001 From: sleepyyapril Date: Wed, 27 Nov 2024 19:00:06 -0400 Subject: [PATCH 03/11] mrow --- .../Administration/Systems/BwoinkSystem.cs | 275 +++++++++++++++--- .../EntitySystems/FoodGuideDataSystem.cs | 2 +- 2 files changed, 230 insertions(+), 47 deletions(-) diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index 0c2a318666..a3440aad53 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -7,12 +7,15 @@ using System.Threading.Tasks; using Content.Server.Administration.Managers; using Content.Server.Afk; +using Content.Server.Database; using Content.Server.Discord; using Content.Server.GameTicking; using Content.Server.Players.RateLimiting; using Content.Shared.Administration; using Content.Shared.CCVar; +using Content.Shared.GameTicking; using Content.Shared.Mind; +using Content.Shared.Players.RateLimiting; using JetBrains.Annotations; using Robust.Server.Player; using Robust.Shared; @@ -38,21 +41,29 @@ public sealed partial class BwoinkSystem : SharedBwoinkSystem [Dependency] private readonly GameTicker _gameTicker = default!; [Dependency] private readonly SharedMindSystem _minds = default!; [Dependency] private readonly IAfkManager _afkManager = default!; + [Dependency] private readonly IServerDbManager _dbManager = default!; [Dependency] private readonly PlayerRateLimitManager _rateLimit = default!; [GeneratedRegex(@"^https://(?:(?:canary|ptb)\.)?discord\.com/api/webhooks/(\d+)/((?!.*/).*)$")] private static partial Regex DiscordRegex(); - private ISawmill _sawmill = default!; - private readonly HttpClient _httpClient = new(); private string _webhookUrl = string.Empty; private WebhookData? _webhookData; + + private string _onCallUrl = string.Empty; + private WebhookData? _onCallData; + + private ISawmill _sawmill = default!; + private readonly HttpClient _httpClient = new(); + private string _footerIconUrl = string.Empty; private string _avatarUrl = string.Empty; private string _serverName = string.Empty; - private readonly Dictionary _relayMessages = new(); + + private readonly Dictionary _relayMessages = new(); + private Dictionary _oldMessageIds = new(); - private readonly Dictionary> _messageQueues = new(); + private readonly Dictionary> _messageQueues = new(); private readonly HashSet _processingChannels = new(); private readonly Dictionary _typingUpdateTimestamps = new(); private string _overrideClientName = string.Empty; @@ -69,17 +80,30 @@ public sealed partial class BwoinkSystem : SharedBwoinkSystem private const string TooLongText = "... **(too long)**"; private int _maxAdditionalChars; + private readonly Dictionary _activeConversations = new(); public override void Initialize() { base.Initialize(); + + Subs.CVar(_config, CCVars.DiscordOnCallWebhook, OnCallChanged, true); + Subs.CVar(_config, CCVars.DiscordAHelpWebhook, OnWebhookChanged, true); Subs.CVar(_config, CCVars.DiscordAHelpFooterIcon, OnFooterIconChanged, true); Subs.CVar(_config, CCVars.DiscordAHelpAvatar, OnAvatarChanged, true); Subs.CVar(_config, CVars.GameHostName, OnServerNameChanged, true); Subs.CVar(_config, CCVars.AdminAhelpOverrideClientName, OnOverrideChanged, true); _sawmill = IoCManager.Resolve().GetSawmill("AHELP"); - _maxAdditionalChars = GenerateAHelpMessage("", "", true, _gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"), _gameTicker.RunLevel, playedSound: false).Length; + + var defaultParams = new AHelpMessageParams( + string.Empty, + string.Empty, + true, + _gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"), + _gameTicker.RunLevel, + playedSound: false + ); + _maxAdditionalChars = GenerateAHelpMessage(defaultParams).Message.Length; _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; SubscribeLocalEvent(OnGameRunLevelChanged); @@ -133,14 +157,129 @@ private void OnOverrideChanged(string obj) _overrideClientName = obj; } - private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) + private async void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) { + if (e.NewStatus == SessionStatus.Disconnected) + { + if (_activeConversations.TryGetValue(e.Session.UserId, out var lastMessageTime)) + { + var timeSinceLastMessage = DateTime.Now - lastMessageTime; + if (timeSinceLastMessage > TimeSpan.FromMinutes(5)) + { + _activeConversations.Remove(e.Session.UserId); + return; // Do not send disconnect message if timeout exceeded + } + } + + // Check if the user has been banned + var ban = await _dbManager.GetServerBanAsync(null, e.Session.UserId, null); + if (ban != null) + { + var banMessage = Loc.GetString("bwoink-system-player-banned", ("banReason", ban.Reason)); + NotifyAdmins(e.Session, banMessage, PlayerStatusType.Banned); + _activeConversations.Remove(e.Session.UserId); + return; + } + } + + // Notify all admins if a player disconnects or reconnects + var message = e.NewStatus switch + { + SessionStatus.Connected => Loc.GetString("bwoink-system-player-reconnecting"), + SessionStatus.Disconnected => Loc.GetString("bwoink-system-player-disconnecting"), + _ => null + }; + + if (message != null) + { + var statusType = e.NewStatus == SessionStatus.Connected + ? PlayerStatusType.Connected + : PlayerStatusType.Disconnected; + NotifyAdmins(e.Session, message, statusType); + } + if (e.NewStatus != SessionStatus.InGame) return; RaiseNetworkEvent(new BwoinkDiscordRelayUpdated(!string.IsNullOrWhiteSpace(_webhookUrl)), e.Session); } + private void NotifyAdmins(ICommonSession session, string message, PlayerStatusType statusType) + { + if (!_activeConversations.ContainsKey(session.UserId)) + { + // If the user is not part of an active conversation, do not notify admins. + return; + } + + // Get the current timestamp + var timestamp = DateTime.Now.ToString("HH:mm:ss"); + var roundTime = _gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"); + + // Determine the icon based on the status type + string icon = statusType switch + { + PlayerStatusType.Connected => ":green_circle:", + PlayerStatusType.Disconnected => ":red_circle:", + PlayerStatusType.Banned => ":no_entry:", + _ => ":question:" + }; + + // Create the message parameters for Discord + var messageParams = new AHelpMessageParams( + session.Name, + message, + true, + roundTime, + _gameTicker.RunLevel, + playedSound: true, + icon: icon + ); + + // Create the message for in-game with username + var color = statusType switch + { + PlayerStatusType.Connected => Color.Green.ToHex(), + PlayerStatusType.Disconnected => Color.Yellow.ToHex(), + PlayerStatusType.Banned => Color.Orange.ToHex(), + _ => Color.Gray.ToHex(), + }; + var inGameMessage = $"[color={color}]{session.Name} {message}[/color]"; + + var bwoinkMessage = new BwoinkTextMessage( + userId: session.UserId, + trueSender: SystemUserId, + text: inGameMessage, + sentAt: DateTime.Now, + playSound: false + ); + + var admins = GetTargetAdmins(); + foreach (var admin in admins) + { + RaiseNetworkEvent(bwoinkMessage, admin); + } + + // Enqueue the message for Discord relay + if (_webhookUrl != string.Empty) + { + // if (!_messageQueues.ContainsKey(session.UserId)) + // _messageQueues[session.UserId] = new Queue(); + // + // var escapedText = FormattedMessage.EscapeText(message); + // messageParams.Message = escapedText; + // + // var discordMessage = GenerateAHelpMessage(messageParams); + // _messageQueues[session.UserId].Enqueue(discordMessage); + + var queue = _messageQueues.GetOrNew(session.UserId); + var escapedText = FormattedMessage.EscapeText(message); + messageParams.Message = escapedText; + var discordMessage = GenerateAHelpMessage(messageParams); + queue.Enqueue(discordMessage); + } + } + private void OnGameRunLevelChanged(GameRunLevelChangedEvent args) { // Don't make a new embed if we @@ -154,13 +293,13 @@ args.New is not (GameRunLevel.PreRoundLobby or GameRunLevel.InRound)) // Store the Discord message IDs of the previous round _oldMessageIds = new Dictionary(); - foreach (var message in _relayMessages) + foreach (var (user, interaction) in _relayMessages) { - var id = message.Value.id; + var id = interaction.Id; if (id == null) return; - _oldMessageIds[message.Key] = id; + _oldMessageIds[user] = id; } _relayMessages.Clear(); @@ -238,7 +377,7 @@ private async void OnWebhookChanged(string url) return null; } - _webhookData = JsonSerializer.Deserialize(content); + return JsonSerializer.Deserialize(content); } private void OnFooterIconChanged(string url) @@ -251,14 +390,14 @@ private void OnAvatarChanged(string url) _avatarUrl = url; } - private async void ProcessQueue(NetUserId userId, Queue messages) + private async void ProcessQueue(NetUserId userId, Queue messages) { // Whether an embed already exists for this player var exists = _relayMessages.TryGetValue(userId, out var existingEmbed); // Whether the message will become too long after adding these new messages - var tooLong = exists && messages.Sum(msg => Math.Min(msg.Length, MessageLengthCap) + "\n".Length) - + existingEmbed.description.Length > DescriptionMax; + var tooLong = exists && messages.Sum(msg => Math.Min(msg.Message.Length, MessageLengthCap) + "\n".Length) + + existingEmbed?.Description.Length > DescriptionMax; // If there is no existing embed, or it is getting too long, we create a new embed if (!exists || tooLong) @@ -267,7 +406,8 @@ private async void ProcessQueue(NetUserId userId, Queue messages) if (lookup == null) { - _sawmill.Log(LogLevel.Error, $"Unable to find player for NetUserId {userId} when sending discord webhook."); + _sawmill.Log(LogLevel.Error, + $"Unable to find player for NetUserId {userId} when sending discord webhook."); _relayMessages.Remove(userId); return; } @@ -277,42 +417,63 @@ private async void ProcessQueue(NetUserId userId, Queue messages) // If we have all the data required, we can link to the embed of the previous round or embed that was too long if (_webhookData is { GuildId: { } guildId, ChannelId: { } channelId }) { - if (tooLong && existingEmbed.id != null) + if (tooLong && existingEmbed?.Id != null) { - linkToPrevious = $"**[Go to previous embed of this round](https://discord.com/channels/{guildId}/{channelId}/{existingEmbed.id})**\n"; + linkToPrevious = + $"**[Go to previous embed of this round](https://discord.com/channels/{guildId}/{channelId}/{existingEmbed.Id})**\n"; } else if (_oldMessageIds.TryGetValue(userId, out var id) && !string.IsNullOrEmpty(id)) { - linkToPrevious = $"**[Go to last round's conversation with this player](https://discord.com/channels/{guildId}/{channelId}/{id})**\n"; + linkToPrevious = + $"**[Go to last round's conversation with this player](https://discord.com/channels/{guildId}/{channelId}/{id})**\n"; } } var characterName = _minds.GetCharacterName(userId); - existingEmbed = (null, lookup.Username, linkToPrevious, characterName, _gameTicker.RunLevel); + existingEmbed = new DiscordRelayInteraction() + { + Id = null, + CharacterName = characterName, + Description = linkToPrevious, + Username = lookup.Username, + LastRunLevel = _gameTicker.RunLevel, + }; + + _relayMessages[userId] = existingEmbed; } // Previous message was in another RunLevel, so show that in the embed - if (existingEmbed.lastRunLevel != _gameTicker.RunLevel) + if (existingEmbed!.LastRunLevel != _gameTicker.RunLevel) { - existingEmbed.description += _gameTicker.RunLevel switch + existingEmbed.Description += _gameTicker.RunLevel switch { GameRunLevel.PreRoundLobby => "\n\n:arrow_forward: _**Pre-round lobby started**_\n", GameRunLevel.InRound => "\n\n:arrow_forward: _**Round started**_\n", GameRunLevel.PostRound => "\n\n:stop_button: _**Post-round started**_\n", - _ => throw new ArgumentOutOfRangeException(nameof(_gameTicker.RunLevel), $"{_gameTicker.RunLevel} was not matched."), + _ => throw new ArgumentOutOfRangeException(nameof(_gameTicker.RunLevel), + $"{_gameTicker.RunLevel} was not matched."), }; - existingEmbed.lastRunLevel = _gameTicker.RunLevel; + existingEmbed.LastRunLevel = _gameTicker.RunLevel; } + // If last message of the new batch is SOS then relay it to on-call. + // ... as long as it hasn't been relayed already. + var discordMention = messages.Last(); + var onCallRelay = !discordMention.Receivers && !existingEmbed.OnCall; + // Add available messages to the embed description while (messages.TryDequeue(out var message)) { + string text; + // In case someone thinks they're funny - if (message.Length > MessageLengthCap) - message = message[..(MessageLengthCap - TooLongText.Length)] + TooLongText; + if (message.Message.Length > MessageLengthCap) + text = message.Message[..(MessageLengthCap - TooLongText.Length)] + TooLongText; + else + text = message.Message; - existingEmbed.description += $"\n{message}"; + existingEmbed.Description += $"\n{text}"; } var payload = GeneratePayload(existingEmbed.Description, @@ -322,7 +483,7 @@ private async void ProcessQueue(NetUserId userId, Queue messages) // If there is no existing embed, create a new one // Otherwise patch (edit) it - if (existingEmbed.id == null) + if (existingEmbed.Id == null) { var request = await _httpClient.PostAsync($"{_webhookUrl}?wait=true", new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); @@ -330,7 +491,8 @@ private async void ProcessQueue(NetUserId userId, Queue messages) var content = await request.Content.ReadAsStringAsync(); if (!request.IsSuccessStatusCode) { - _sawmill.Log(LogLevel.Error, $"Discord returned bad status code when posting message (perhaps the message is too long?): {request.StatusCode}\nResponse: {content}"); + _sawmill.Log(LogLevel.Error, + $"Discord returned bad status code when posting message (perhaps the message is too long?): {request.StatusCode}\nResponse: {content}"); _relayMessages.Remove(userId); return; } @@ -338,22 +500,24 @@ private async void ProcessQueue(NetUserId userId, Queue messages) var id = JsonNode.Parse(content)?["id"]; if (id == null) { - _sawmill.Log(LogLevel.Error, $"Could not find id in json-content returned from discord webhook: {content}"); + _sawmill.Log(LogLevel.Error, + $"Could not find id in json-content returned from discord webhook: {content}"); _relayMessages.Remove(userId); return; } - existingEmbed.id = id.ToString(); + existingEmbed.Id = id.ToString(); } else { - var request = await _httpClient.PatchAsync($"{_webhookUrl}/messages/{existingEmbed.id}", + var request = await _httpClient.PatchAsync($"{_webhookUrl}/messages/{existingEmbed.Id}", new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); if (!request.IsSuccessStatusCode) { var content = await request.Content.ReadAsStringAsync(); - _sawmill.Log(LogLevel.Error, $"Discord returned bad status code when patching message (perhaps the message is too long?): {request.StatusCode}\nResponse: {content}"); + _sawmill.Log(LogLevel.Error, + $"Discord returned bad status code when patching message (perhaps the message is too long?): {request.StatusCode}\nResponse: {content}"); _relayMessages.Remove(userId); return; } @@ -420,7 +584,8 @@ private WebhookPayload GeneratePayload(string messages, string username, Guid us : $"pre-round lobby for round {_gameTicker.RoundId + 1}", GameRunLevel.InRound => $"round {_gameTicker.RoundId}", GameRunLevel.PostRound => $"post-round {_gameTicker.RoundId}", - _ => throw new ArgumentOutOfRangeException(nameof(_gameTicker.RunLevel), $"{_gameTicker.RunLevel} was not matched."), + _ => throw new ArgumentOutOfRangeException(nameof(_gameTicker.RunLevel), + $"{_gameTicker.RunLevel} was not matched."), }; return new WebhookPayload @@ -515,8 +680,17 @@ private void OnBwoinkInternal(BwoinkTextMessage message, NetUserId senderId, Adm var escapedText = FormattedMessage.EscapeText(message.Text); string bwoinkText; + string adminPrefix = ""; + + //Getting an administrator position + if (_config.GetCVar(CCVars.AhelpAdminPrefix) && senderAdmin is not null && senderAdmin.Title is not null) + { + adminPrefix = $"[bold]\\[{senderAdmin.Title}\\][/bold] "; + } - if (senderAdmin is not null && senderAdmin.Flags == AdminFlags.Adminhelp) // Mentor. Not full admin. That's why it's colored differently. + if (senderAdmin is not null && + senderAdmin.Flags == + AdminFlags.Adminhelp) // Mentor. Not full admin. That's why it's colored differently. { bwoinkText = $"[color=purple]{adminPrefix}{senderName}[/color]"; } @@ -543,9 +717,12 @@ private void OnBwoinkInternal(BwoinkTextMessage message, NetUserId senderId, Adm var admins = GetTargetAdmins(); // Notify all admins - foreach (var channel in admins) + if (!userOnly) { - RaiseNetworkEvent(msg, channel); + foreach (var channel in admins) + { + RaiseNetworkEvent(msg, channel); + } } string adminPrefixWebhook = ""; @@ -565,13 +742,15 @@ private void OnBwoinkInternal(BwoinkTextMessage message, NetUserId senderId, Adm { string overrideMsgText; // Doing the same thing as above, but with the override name. Theres probably a better way to do this. - if (senderAdmin is not null && senderAdmin.Flags == AdminFlags.Adminhelp) // Mentor. Not full admin. That's why it's colored differently. + if (senderAdmin is not null && + senderAdmin.Flags == + AdminFlags.Adminhelp) // Mentor. Not full admin. That's why it's colored differently. { - overrideMsgText = $"[color=purple]{_overrideClientName}[/color]"; + overrideMsgText = $"[color=purple]{adminPrefixWebhook}{_overrideClientName}[/color]"; } else if (senderAdmin is not null && senderAdmin.HasFlag(AdminFlags.Adminhelp)) { - overrideMsgText = $"[color=red]{_overrideClientName}[/color]"; + overrideMsgText = $"[color=red]{adminPrefixWebhook}{_overrideClientName}[/color]"; } else { @@ -598,7 +777,7 @@ private void OnBwoinkInternal(BwoinkTextMessage message, NetUserId senderId, Adm if (sendsWebhook && sendWebhook) { if (!_messageQueues.ContainsKey(msg.UserId)) - _messageQueues[msg.UserId] = new Queue(); + _messageQueues[msg.UserId] = new Queue(); var str = message.Text; var unameLength = senderName.Length; @@ -607,6 +786,7 @@ private void OnBwoinkInternal(BwoinkTextMessage message, NetUserId senderId, Adm { str = str[..(DescriptionMax - _maxAdditionalChars - unameLength)]; } + var nonAfkAdmins = GetNonAfkAdmins(); var messageParams = new AHelpMessageParams( senderName, @@ -637,7 +817,8 @@ private void OnBwoinkInternal(BwoinkTextMessage message, NetUserId senderId, Adm private IList GetNonAfkAdmins() { return _adminManager.ActiveAdmins - .Where(p => (_adminManager.GetAdminData(p)?.HasFlag(AdminFlags.Adminhelp) ?? false) && !_afkManager.IsAfk(p)) + .Where(p => (_adminManager.GetAdminData(p)?.HasFlag(AdminFlags.Adminhelp) ?? false) && + !_afkManager.IsAfk(p)) .Select(p => p.Channel) .ToList(); } @@ -650,20 +831,22 @@ private IList GetTargetAdmins() .ToList(); } - private static string GenerateAHelpMessage(string username, string message, bool admin, string roundTime, GameRunLevel roundState, bool playedSound, bool noReceivers = false) + private static DiscordRelayedData GenerateAHelpMessage(AHelpMessageParams parameters) { var stringbuilder = new StringBuilder(); - if (admin) + if (parameters.Icon != null) + stringbuilder.Append(parameters.Icon); + else if (parameters.IsAdmin) stringbuilder.Append(":outbox_tray:"); - else if (noReceivers) + else if (parameters.NoReceivers) stringbuilder.Append(":sos:"); else stringbuilder.Append(":inbox_tray:"); - if(roundTime != string.Empty && roundState == GameRunLevel.InRound) - stringbuilder.Append($" **{roundTime}**"); - if (!playedSound) + if (parameters.RoundTime != string.Empty && parameters.RoundState == GameRunLevel.InRound) + stringbuilder.Append($" **{parameters.RoundTime}**"); + if (!parameters.PlayedSound) stringbuilder.Append(" **(S)**"); if (parameters.IsDiscord) // Frontier - Discord Indicator diff --git a/Content.Server/Nutrition/EntitySystems/FoodGuideDataSystem.cs b/Content.Server/Nutrition/EntitySystems/FoodGuideDataSystem.cs index f21c509ace..e3077476ce 100644 --- a/Content.Server/Nutrition/EntitySystems/FoodGuideDataSystem.cs +++ b/Content.Server/Nutrition/EntitySystems/FoodGuideDataSystem.cs @@ -116,7 +116,7 @@ public void ReloadRecipes() // We filter out food without whitelisted reagents because well when people look for food they usually expect FOOD and not insulated gloves. // And we get insulated and other gloves because they have ButcherableComponent and they are also moth food - if (!composition.Any(it => ReagentWhitelist.Contains(it.Reagent.Prototype))) + if (!composition.Any(it => ReagentWhitelist.Contains>(it.Reagent.Prototype))) continue; // We also limit the number of sources to 10 because it's a huge performance strain to render 500 raw meat recipes. From 44460b22e0cb3f75b3616d63107776506da8467e Mon Sep 17 00:00:00 2001 From: to4no_fix <156101927+chavonadelal@users.noreply.github.com> Date: Sat, 3 Aug 2024 06:59:20 +0300 Subject: [PATCH 04/11] Display the administrator's title in ahelp and ahelp relay (#30075) * Adding the admin prefix to the ahelp * Updating the admin prefix * The second update of the admin prefix * Configuration correction --- .../Administration/Managers/AdminManager.cs | 2 +- Content.Shared/CCVar/CCVars.cs | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Content.Server/Administration/Managers/AdminManager.cs b/Content.Server/Administration/Managers/AdminManager.cs index 205024a71e..2b74a6d5ac 100644 --- a/Content.Server/Administration/Managers/AdminManager.cs +++ b/Content.Server/Administration/Managers/AdminManager.cs @@ -454,7 +454,7 @@ private async void LoginAdminMaybe(ICommonSession session) Flags = flags }; - if (dbData.Title != null) + if (dbData.Title != null && _cfg.GetCVar(CCVars.AdminUseCustomNamesAdminRank)) { data.Title = dbData.Title; } diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index b5479567e7..d1d36fb1dc 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -950,6 +950,22 @@ public static readonly CVarDef public static readonly CVarDef AdminAfkTime = CVarDef.Create("admin.afk_time", 600f, CVar.SERVERONLY); + /// + /// If true, admins are able to connect even if + /// would otherwise block regular players. + /// + public static readonly CVarDef AdminBypassMaxPlayers = + CVarDef.Create("admin.bypass_max_players", true, CVar.SERVERONLY); + + /// + /// Determine if custom rank names are used. + /// If it is false, it'd use the actual rank name regardless of the individual's title. + /// + /// + /// + public static readonly CVarDef AdminUseCustomNamesAdminRank = + CVarDef.Create("admin.use_custom_names_admin_rank", true, CVar.SERVERONLY); + /* * AHELP */ @@ -969,6 +985,24 @@ public static readonly CVarDef public static readonly CVarDef AhelpRateLimitCount = CVarDef.Create("ahelp.rate_limit_count", 10, CVar.SERVERONLY); + /// + /// Should the administrator's position be displayed in ahelp. + /// If it is is false, only the admin's ckey will be displayed in the ahelp. + /// + /// + /// + public static readonly CVarDef AhelpAdminPrefix = + CVarDef.Create("ahelp.admin_prefix", true, CVar.SERVERONLY); + + /// + /// Should the administrator's position be displayed in the webhook. + /// If it is is false, only the admin's ckey will be displayed in webhook. + /// + /// + /// + public static readonly CVarDef AhelpAdminPrefixWebhook = + CVarDef.Create("ahelp.admin_prefix_webhook", true, CVar.SERVERONLY); + /* * Explosions */ From 4f46c95b873830e70197f07faf79cd0d14141de9 Mon Sep 17 00:00:00 2001 From: Repo <47093363+Titian3@users.noreply.github.com> Date: Tue, 30 Jul 2024 20:28:32 +1200 Subject: [PATCH 05/11] aHelp fixes and improvements (#28639) * Clear search criteria on loading aHelp window * Pinning technology. * Relay to aHelp window and discord if a user disconnect/reconnect * Fix pinning localization * Log disconnect, reconnects, bans to relay and admin in aHelp * Drop to 5min to hold active conversations * Update Content.Server/Administration/Systems/BwoinkSystem.cs Co-authored-by: Chief-Engineer <119664036+Chief-Engineer@users.noreply.github.com> * discord text styling if diconnect,reconnect,banned message. * Pin icons instead of text * Better Icons * space * Move button generation in to its own XAML * List entry control * Fix spaces * Remove from active conversations on banned * Discord if else block cleanup * Better pin icons * Move icons to stylesheet styleclass * Better field order. * PR review fixes * fixes --------- Co-authored-by: Chief-Engineer <119664036+Chief-Engineer@users.noreply.github.com> Co-authored-by: metalgearsloth --- .../UI/Bwoink/BwoinkWindow.xaml.cs | 6 +- .../CustomControls/PlayerListControl.xaml.cs | 230 +++++++++--------- .../UI/CustomControls/PlayerListEntry.xaml | 6 + .../UI/CustomControls/PlayerListEntry.xaml.cs | 58 +++++ Content.Client/Stylesheets/StyleNano.cs | 20 ++ .../Administration/Systems/BwoinkSystem.cs | 8 +- Content.Shared/Administration/PlayerInfo.cs | 2 + .../Locale/en-US/administration/bwoink.ftl | 3 + .../Textures/Interface/Bwoink/pinned.png | Bin 0 -> 2267 bytes .../Textures/Interface/Bwoink/pinned2.png | Bin 0 -> 2028 bytes .../Textures/Interface/Bwoink/un_pinned.png | Bin 0 -> 2151 bytes 11 files changed, 216 insertions(+), 117 deletions(-) create mode 100644 Content.Client/Administration/UI/CustomControls/PlayerListEntry.xaml create mode 100644 Content.Client/Administration/UI/CustomControls/PlayerListEntry.xaml.cs create mode 100644 Resources/Textures/Interface/Bwoink/pinned.png create mode 100644 Resources/Textures/Interface/Bwoink/pinned2.png create mode 100644 Resources/Textures/Interface/Bwoink/un_pinned.png diff --git a/Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs b/Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs index f8d06f758f..7c0a912abd 100644 --- a/Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs +++ b/Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs @@ -27,7 +27,11 @@ public BwoinkWindow() } }; - OnOpen += () => Bwoink.PopulateList(); + OnOpen += () => + { + Bwoink.ChannelSelector.StopFiltering(); + Bwoink.PopulateList(); + }; } } } diff --git a/Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml.cs b/Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml.cs index fdf935d7c0..b09cd727ef 100644 --- a/Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml.cs +++ b/Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml.cs @@ -4,147 +4,155 @@ using Content.Client.Verbs.UI; using Content.Shared.Administration; using Robust.Client.AutoGenerated; +using Robust.Client.GameObjects; using Robust.Client.Graphics; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.XAML; using Robust.Shared.Input; +using Robust.Shared.Utility; -namespace Content.Client.Administration.UI.CustomControls +namespace Content.Client.Administration.UI.CustomControls; + +[GenerateTypedNameReferences] +public sealed partial class PlayerListControl : BoxContainer { - [GenerateTypedNameReferences] - public sealed partial class PlayerListControl : BoxContainer - { - private readonly AdminSystem _adminSystem; + private readonly AdminSystem _adminSystem; - private List _playerList = new(); - private readonly List _sortedPlayerList = new(); + private readonly IEntityManager _entManager; + private readonly IUserInterfaceManager _uiManager; + + private PlayerInfo? _selectedPlayer; - public event Action? OnSelectionChanged; - public IReadOnlyList PlayerInfo => _playerList; + private List _playerList = new(); + private readonly List _sortedPlayerList = new(); - public Func? OverrideText; - public Comparison? Comparison; + public Comparison? Comparison; + public Func? OverrideText; - private IEntityManager _entManager; - private IUserInterfaceManager _uiManager; + public PlayerListControl() + { + _entManager = IoCManager.Resolve(); + _uiManager = IoCManager.Resolve(); + _adminSystem = _entManager.System(); + RobustXamlLoader.Load(this); + // Fill the Option data + PlayerListContainer.ItemPressed += PlayerListItemPressed; + PlayerListContainer.ItemKeyBindDown += PlayerListItemKeyBindDown; + PlayerListContainer.GenerateItem += GenerateButton; + PlayerListContainer.NoItemSelected += PlayerListNoItemSelected; + PopulateList(_adminSystem.PlayerList); + FilterLineEdit.OnTextChanged += _ => FilterList(); + _adminSystem.PlayerListChanged += PopulateList; + BackgroundPanel.PanelOverride = new StyleBoxFlat { BackgroundColor = new Color(32, 32, 40) }; + } - private PlayerInfo? _selectedPlayer; + public IReadOnlyList PlayerInfo => _playerList; - public PlayerListControl() - { - _entManager = IoCManager.Resolve(); - _uiManager = IoCManager.Resolve(); - _adminSystem = _entManager.System(); - RobustXamlLoader.Load(this); - // Fill the Option data - PlayerListContainer.ItemPressed += PlayerListItemPressed; - PlayerListContainer.ItemKeyBindDown += PlayerListItemKeyBindDown; - PlayerListContainer.GenerateItem += GenerateButton; - PopulateList(_adminSystem.PlayerList); - FilterLineEdit.OnTextChanged += _ => FilterList(); - _adminSystem.PlayerListChanged += PopulateList; - BackgroundPanel.PanelOverride = new StyleBoxFlat {BackgroundColor = new Color(32, 32, 40)}; - } + public event Action? OnSelectionChanged; - private void PlayerListItemPressed(BaseButton.ButtonEventArgs? args, ListData? data) - { - if (args == null || data is not PlayerListData {Info: var selectedPlayer}) - return; + private void PlayerListNoItemSelected() + { + _selectedPlayer = null; + OnSelectionChanged?.Invoke(null); + } - if (selectedPlayer == _selectedPlayer) - return; + private void PlayerListItemPressed(BaseButton.ButtonEventArgs? args, ListData? data) + { + if (args == null || data is not PlayerListData { Info: var selectedPlayer }) + return; - if (args.Event.Function != EngineKeyFunctions.UIClick) - return; + if (selectedPlayer == _selectedPlayer) + return; - OnSelectionChanged?.Invoke(selectedPlayer); - _selectedPlayer = selectedPlayer; + if (args.Event.Function != EngineKeyFunctions.UIClick) + return; - // update label text. Only required if there is some override (e.g. unread bwoink count). - if (OverrideText != null && args.Button.Children.FirstOrDefault()?.Children?.FirstOrDefault() is Label label) - label.Text = GetText(selectedPlayer); - } + OnSelectionChanged?.Invoke(selectedPlayer); + _selectedPlayer = selectedPlayer; - private void PlayerListItemKeyBindDown(GUIBoundKeyEventArgs? args, ListData? data) - { - if (args == null || data is not PlayerListData { Info: var selectedPlayer }) - return; + // update label text. Only required if there is some override (e.g. unread bwoink count). + if (OverrideText != null && args.Button.Children.FirstOrDefault()?.Children?.FirstOrDefault() is Label label) + label.Text = GetText(selectedPlayer); + } - if (args.Function != EngineKeyFunctions.UIRightClick || selectedPlayer.NetEntity == null) - return; + private void PlayerListItemKeyBindDown(GUIBoundKeyEventArgs? args, ListData? data) + { + if (args == null || data is not PlayerListData { Info: var selectedPlayer }) + return; - _uiManager.GetUIController().OpenVerbMenu(selectedPlayer.NetEntity.Value, true); - args.Handle(); - } + if (args.Function != EngineKeyFunctions.UIRightClick || selectedPlayer.NetEntity == null) + return; - public void StopFiltering() - { - FilterLineEdit.Text = string.Empty; - } + _uiManager.GetUIController().OpenVerbMenu(selectedPlayer.NetEntity.Value, true); + args.Handle(); + } - private void FilterList() + public void StopFiltering() + { + FilterLineEdit.Text = string.Empty; + } + + private void FilterList() + { + _sortedPlayerList.Clear(); + foreach (var info in _playerList) { - _sortedPlayerList.Clear(); - foreach (var info in _playerList) - { - var displayName = $"{info.CharacterName} ({info.Username})"; - if (info.IdentityName != info.CharacterName) - displayName += $" [{info.IdentityName}]"; - if (!string.IsNullOrEmpty(FilterLineEdit.Text) - && !displayName.ToLowerInvariant().Contains(FilterLineEdit.Text.Trim().ToLowerInvariant())) - continue; - _sortedPlayerList.Add(info); - } - - if (Comparison != null) - _sortedPlayerList.Sort((a, b) => Comparison(a, b)); - - PlayerListContainer.PopulateList(_sortedPlayerList.Select(info => new PlayerListData(info)).ToList()); - if (_selectedPlayer != null) - PlayerListContainer.Select(new PlayerListData(_selectedPlayer)); + var displayName = $"{info.CharacterName} ({info.Username})"; + if (info.IdentityName != info.CharacterName) + displayName += $" [{info.IdentityName}]"; + if (!string.IsNullOrEmpty(FilterLineEdit.Text) + && !displayName.ToLowerInvariant().Contains(FilterLineEdit.Text.Trim().ToLowerInvariant())) + continue; + _sortedPlayerList.Add(info); } - public void PopulateList(IReadOnlyList? players = null) - { - players ??= _adminSystem.PlayerList; + if (Comparison != null) + _sortedPlayerList.Sort((a, b) => Comparison(a, b)); - _playerList = players.ToList(); - if (_selectedPlayer != null && !_playerList.Contains(_selectedPlayer)) - _selectedPlayer = null; + // Ensure pinned players are always at the top + _sortedPlayerList.Sort((a, b) => a.IsPinned != b.IsPinned && a.IsPinned ? -1 : 1); - FilterList(); - } + PlayerListContainer.PopulateList(_sortedPlayerList.Select(info => new PlayerListData(info)).ToList()); + if (_selectedPlayer != null) + PlayerListContainer.Select(new PlayerListData(_selectedPlayer)); + } - private string GetText(PlayerInfo info) - { - var text = $"{info.CharacterName} ({info.Username})"; - if (OverrideText != null) - text = OverrideText.Invoke(info, text); - return text; - } + public void PopulateList(IReadOnlyList? players = null) + { + players ??= _adminSystem.PlayerList; - private void GenerateButton(ListData data, ListContainerButton button) - { - if (data is not PlayerListData { Info: var info }) - return; - - button.AddChild(new BoxContainer - { - Orientation = LayoutOrientation.Vertical, - Children = - { - new Label - { - ClipText = true, - Text = GetText(info) - } - } - }); - - button.AddStyleClass(ListContainer.StyleClassListContainerButton); - } + _playerList = players.ToList(); + if (_selectedPlayer != null && !_playerList.Contains(_selectedPlayer)) + _selectedPlayer = null; + + FilterList(); } - public record PlayerListData(PlayerInfo Info) : ListData; + + private string GetText(PlayerInfo info) + { + var text = $"{info.CharacterName} ({info.Username})"; + if (OverrideText != null) + text = OverrideText.Invoke(info, text); + return text; + } + + private void GenerateButton(ListData data, ListContainerButton button) + { + if (data is not PlayerListData { Info: var info }) + return; + + var entry = new PlayerListEntry(); + entry.Setup(info, OverrideText); + entry.OnPinStatusChanged += _ => + { + FilterList(); + }; + + button.AddChild(entry); + button.AddStyleClass(ListContainer.StyleClassListContainerButton); + } } + +public record PlayerListData(PlayerInfo Info) : ListData; diff --git a/Content.Client/Administration/UI/CustomControls/PlayerListEntry.xaml b/Content.Client/Administration/UI/CustomControls/PlayerListEntry.xaml new file mode 100644 index 0000000000..af13ccc0e0 --- /dev/null +++ b/Content.Client/Administration/UI/CustomControls/PlayerListEntry.xaml @@ -0,0 +1,6 @@ + + diff --git a/Content.Client/Administration/UI/CustomControls/PlayerListEntry.xaml.cs b/Content.Client/Administration/UI/CustomControls/PlayerListEntry.xaml.cs new file mode 100644 index 0000000000..cd6a56ea71 --- /dev/null +++ b/Content.Client/Administration/UI/CustomControls/PlayerListEntry.xaml.cs @@ -0,0 +1,58 @@ +using Content.Client.Stylesheets; +using Content.Shared.Administration; +using Robust.Client.AutoGenerated; +using Robust.Client.GameObjects; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Utility; + +namespace Content.Client.Administration.UI.CustomControls; + +[GenerateTypedNameReferences] +public sealed partial class PlayerListEntry : BoxContainer +{ + public PlayerListEntry() + { + RobustXamlLoader.Load(this); + } + + public event Action? OnPinStatusChanged; + + public void Setup(PlayerInfo info, Func? overrideText) + { + Update(info, overrideText); + PlayerEntryPinButton.OnPressed += HandlePinButtonPressed(info); + } + + private Action HandlePinButtonPressed(PlayerInfo info) + { + return args => + { + info.IsPinned = !info.IsPinned; + UpdatePinButtonTexture(info.IsPinned); + OnPinStatusChanged?.Invoke(info); + }; + } + + private void Update(PlayerInfo info, Func? overrideText) + { + PlayerEntryLabel.Text = overrideText?.Invoke(info, $"{info.CharacterName} ({info.Username})") ?? + $"{info.CharacterName} ({info.Username})"; + + UpdatePinButtonTexture(info.IsPinned); + } + + private void UpdatePinButtonTexture(bool isPinned) + { + if (isPinned) + { + PlayerEntryPinButton?.RemoveStyleClass(StyleNano.StyleClassPinButtonUnpinned); + PlayerEntryPinButton?.AddStyleClass(StyleNano.StyleClassPinButtonPinned); + } + else + { + PlayerEntryPinButton?.RemoveStyleClass(StyleNano.StyleClassPinButtonPinned); + PlayerEntryPinButton?.AddStyleClass(StyleNano.StyleClassPinButtonUnpinned); + } + } +} diff --git a/Content.Client/Stylesheets/StyleNano.cs b/Content.Client/Stylesheets/StyleNano.cs index b2d2bf3b48..fd6ad15fe0 100644 --- a/Content.Client/Stylesheets/StyleNano.cs +++ b/Content.Client/Stylesheets/StyleNano.cs @@ -154,6 +154,11 @@ public sealed class StyleNano : StyleBase public static readonly Color ChatBackgroundColor = Color.FromHex("#25252ADD"); + //Bwoink + public const string StyleClassPinButtonPinned = "pinButtonPinned"; + public const string StyleClassPinButtonUnpinned = "pinButtonUnpinned"; + + public override Stylesheet Stylesheet { get; } public StyleNano(IResourceCache resCache) : base(resCache) @@ -1621,6 +1626,21 @@ public StyleNano(IResourceCache resCache) : base(resCache) { BackgroundColor = FancyTreeSelectedRowColor, }), + // Pinned button style + new StyleRule( + new SelectorElement(typeof(TextureButton), new[] { StyleClassPinButtonPinned }, null, null), + new[] + { + new StyleProperty(TextureButton.StylePropertyTexture, resCache.GetTexture("/Textures/Interface/Bwoink/pinned.png")) + }), + + // Unpinned button style + new StyleRule( + new SelectorElement(typeof(TextureButton), new[] { StyleClassPinButtonUnpinned }, null, null), + new[] + { + new StyleProperty(TextureButton.StylePropertyTexture, resCache.GetTexture("/Textures/Interface/Bwoink/un_pinned.png")) + }) // Shitmed Edit Start Element().Class("TargetDollButtonHead") .Pseudo(TextureButton.StylePseudoClassHover) diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index a3440aad53..19786d816b 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -61,7 +61,6 @@ public sealed partial class BwoinkSystem : SharedBwoinkSystem private string _serverName = string.Empty; private readonly Dictionary _relayMessages = new(); - private Dictionary _oldMessageIds = new(); private readonly Dictionary> _messageQueues = new(); private readonly HashSet _processingChannels = new(); @@ -94,7 +93,6 @@ public override void Initialize() Subs.CVar(_config, CVars.GameHostName, OnServerNameChanged, true); Subs.CVar(_config, CCVars.AdminAhelpOverrideClientName, OnOverrideChanged, true); _sawmill = IoCManager.Resolve().GetSawmill("AHELP"); - var defaultParams = new AHelpMessageParams( string.Empty, string.Empty, @@ -103,7 +101,7 @@ public override void Initialize() _gameTicker.RunLevel, playedSound: false ); - _maxAdditionalChars = GenerateAHelpMessage(defaultParams).Message.Length; + _maxAdditionalChars = GenerateAHelpMessage(defaultParams).Length; _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; SubscribeLocalEvent(OnGameRunLevelChanged); @@ -642,7 +640,7 @@ public void OnWebhookBwoinkTextMessage(BwoinkTextMessage message, ServerApi.Bwoi protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySessionEventArgs eventArgs) { base.OnBwoinkTextMessage(message, eventArgs); - + _activeConversations[message.UserId] = DateTime.Now; var senderSession = eventArgs.SenderSession; // TODO: Sanitize text? @@ -763,7 +761,7 @@ private void OnBwoinkInternal(BwoinkTextMessage message, NetUserId senderId, Adm overrideMsgText = $"{(message.PlaySound ? "" : "(S) ")}{overrideMsgText}: {escapedText}"; RaiseNetworkEvent(new BwoinkTextMessage(message.UserId, - senderId, + senderSession.UserId, overrideMsgText, playSound: playSound), session.Channel); diff --git a/Content.Shared/Administration/PlayerInfo.cs b/Content.Shared/Administration/PlayerInfo.cs index 93f1aa0b39..ed54d57bbe 100644 --- a/Content.Shared/Administration/PlayerInfo.cs +++ b/Content.Shared/Administration/PlayerInfo.cs @@ -18,6 +18,8 @@ public sealed record PlayerInfo( { private string? _playtimeString; + public bool IsPinned { get; set; } + public string PlaytimeString => _playtimeString ??= OverallPlaytime?.ToString("%d':'hh':'mm") ?? Loc.GetString("generic-unknown-title"); diff --git a/Resources/Locale/en-US/administration/bwoink.ftl b/Resources/Locale/en-US/administration/bwoink.ftl index 3a92f58ad1..e43932dc1d 100644 --- a/Resources/Locale/en-US/administration/bwoink.ftl +++ b/Resources/Locale/en-US/administration/bwoink.ftl @@ -16,3 +16,6 @@ admin-bwoink-play-sound = Bwoink? bwoink-title-none-selected = None selected bwoink-system-rate-limited = System: you are sending messages too quickly. +bwoink-system-player-disconnecting = has disconnected. +bwoink-system-player-reconnecting = has reconnected. +bwoink-system-player-banned = has been banned for: {$banReason} diff --git a/Resources/Textures/Interface/Bwoink/pinned.png b/Resources/Textures/Interface/Bwoink/pinned.png new file mode 100644 index 0000000000000000000000000000000000000000..80c8a202005649d8b1f81cba5a9a67e8c38b8118 GIT binary patch literal 2267 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 literal 0 HcmV?d00001 diff --git a/Resources/Textures/Interface/Bwoink/pinned2.png b/Resources/Textures/Interface/Bwoink/pinned2.png new file mode 100644 index 0000000000000000000000000000000000000000..11c4b62f123e9a93fedc231390652f33f76f6d9b GIT binary patch literal 2028 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 literal 0 HcmV?d00001 From 7eb097fb4c83e7958d1fd419f6159539bc7eb65f Mon Sep 17 00:00:00 2001 From: sleepyyapril Date: Wed, 27 Nov 2024 19:19:15 -0400 Subject: [PATCH 06/11] mrow! --- Content.Shared/CCVar/CCVars.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index d1d36fb1dc..202ad0058e 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -500,6 +500,18 @@ public static readonly CVarDef * Discord */ + /// + /// The role that will get mentioned if a new SOS ahelp comes in. + /// + public static readonly CVarDef DiscordAhelpMention = + CVarDef.Create("discord.on_call_ping", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL); + + /// + /// URL of the discord webhook to relay unanswered ahelp messages. + /// + public static readonly CVarDef DiscordOnCallWebhook = + CVarDef.Create("discord.on_call_webhook", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL); + /// /// URL of the Discord webhook which will relay all ahelp messages. /// @@ -2715,12 +2727,12 @@ public static readonly CVarDef CVarDef.Create("vote.autovote_enabled", false, CVar.SERVERONLY); /// Automatically starts a map vote when returning to the lobby. - /// Requires auto voting to be enabled. + /// Requires auto voting to be enabled. public static readonly CVarDef MapAutoVoteEnabled = CVarDef.Create("vote.map_autovote_enabled", true, CVar.SERVERONLY); /// Automatically starts a gamemode vote when returning to the lobby. - /// Requires auto voting to be enabled. + /// Requires auto voting to be enabled. public static readonly CVarDef PresetAutoVoteEnabled = CVarDef.Create("vote.preset_autovote_enabled", true, CVar.SERVERONLY); } From 15acc81a5c889b4674c99f98606485f94ad05b9c Mon Sep 17 00:00:00 2001 From: Leon Friedrich <60421075+ElectroJr@users.noreply.github.com> Date: Mon, 30 Sep 2024 01:19:00 +1300 Subject: [PATCH 07/11] Add interaction rate limits (#32527) * Move PlayerRateLimitManager to shared * Add interaction rate limits * uncap tests --- Content.Client/Chat/Managers/ChatManager.cs | 10 ++ Content.Client/Chat/Managers/IChatManager.cs | 4 +- Content.Client/IoC/ClientContentIoC.cs | 9 +- .../RateLimiting/PlayerRateLimitManager.cs | 23 ++++ Content.IntegrationTests/PoolManager.Cvars.cs | 4 +- .../Administration/Systems/BwoinkSystem.cs | 8 ++ .../Chat/Managers/ChatManager.RateLimit.cs | 21 ++- Content.Server/Chat/Managers/ChatManager.cs | 1 + Content.Server/Chat/Managers/IChatManager.cs | 10 +- Content.Server/Chat/Systems/ChatSystem.cs | 1 + Content.Server/IoC/ServerContentIoC.cs | 5 + .../RateLimiting/PlayerRateLimitManager.cs | 122 ++---------------- Content.Shared/CCVar/CCVars.cs | 47 +++++-- Content.Shared/Chat/ISharedChatManager.cs | 8 ++ .../Interaction/SharedInteractionSystem.cs | 31 ++++- .../RateLimiting/RateLimitRegistration.cs | 76 +++++++++++ .../SharedPlayerRateLimitManager.cs | 55 ++++++++ .../en-US/interaction/interaction-system.ftl | 3 +- 18 files changed, 282 insertions(+), 156 deletions(-) create mode 100644 Content.Client/Players/RateLimiting/PlayerRateLimitManager.cs create mode 100644 Content.Shared/Chat/ISharedChatManager.cs create mode 100644 Content.Shared/Players/RateLimiting/RateLimitRegistration.cs create mode 100644 Content.Shared/Players/RateLimiting/SharedPlayerRateLimitManager.cs diff --git a/Content.Client/Chat/Managers/ChatManager.cs b/Content.Client/Chat/Managers/ChatManager.cs index 27b126a5c0..ffb21dcd93 100644 --- a/Content.Client/Chat/Managers/ChatManager.cs +++ b/Content.Client/Chat/Managers/ChatManager.cs @@ -21,6 +21,16 @@ public void Initialize() _sawmill.Level = LogLevel.Info; } + public void SendAdminAlert(string message) + { + // See server-side manager. This just exists for shared code. + } + + public void SendAdminAlert(EntityUid player, string message) + { + // See server-side manager. This just exists for shared code. + } + public void SendMessage(string text, ChatSelectChannel channel) { var str = text.ToString(); diff --git a/Content.Client/Chat/Managers/IChatManager.cs b/Content.Client/Chat/Managers/IChatManager.cs index a21a8194fd..f731798197 100644 --- a/Content.Client/Chat/Managers/IChatManager.cs +++ b/Content.Client/Chat/Managers/IChatManager.cs @@ -2,10 +2,8 @@ namespace Content.Client.Chat.Managers { - public interface IChatManager + public interface IChatManager : ISharedChatManager { - void Initialize(); - public void SendMessage(string text, ChatSelectChannel channel); /// diff --git a/Content.Client/IoC/ClientContentIoC.cs b/Content.Client/IoC/ClientContentIoC.cs index 36c783f068..8ae87779f8 100644 --- a/Content.Client/IoC/ClientContentIoC.cs +++ b/Content.Client/IoC/ClientContentIoC.cs @@ -19,10 +19,12 @@ using Content.Shared.Administration.Logs; using Content.Client.Guidebook; using Content.Client.Lobby; +using Content.Client.Players.RateLimiting; using Content.Client.Replay; using Content.Shared.Administration.Managers; +using Content.Shared.Chat; using Content.Shared.Players.PlayTimeTracking; - +using Content.Shared.Players.RateLimiting; namespace Content.Client.IoC { @@ -34,6 +36,7 @@ public static void Register() collection.Register(); collection.Register(); + collection.Register(); collection.Register(); collection.Register(); collection.Register(); @@ -51,10 +54,12 @@ public static void Register() collection.Register(); collection.Register(); collection.Register(); - collection.Register(); + collection.Register(); collection.Register(); IoCManager.Register(); IoCManager.Register(); + collection.Register(); + collection.Register(); } } } diff --git a/Content.Client/Players/RateLimiting/PlayerRateLimitManager.cs b/Content.Client/Players/RateLimiting/PlayerRateLimitManager.cs new file mode 100644 index 0000000000..e79eadd92b --- /dev/null +++ b/Content.Client/Players/RateLimiting/PlayerRateLimitManager.cs @@ -0,0 +1,23 @@ +using Content.Shared.Players.RateLimiting; +using Robust.Shared.Player; + +namespace Content.Client.Players.RateLimiting; + +public sealed class PlayerRateLimitManager : SharedPlayerRateLimitManager +{ + public override RateLimitStatus CountAction(ICommonSession player, string key) + { + // TODO Rate-Limit + // Add support for rate limit prediction + // I.e., dont mis-predict just because somebody is clicking too quickly. + return RateLimitStatus.Allowed; + } + + public override void Register(string key, RateLimitRegistration registration) + { + } + + public override void Initialize() + { + } +} diff --git a/Content.IntegrationTests/PoolManager.Cvars.cs b/Content.IntegrationTests/PoolManager.Cvars.cs index 5acd9d502c..8d65dd69ed 100644 --- a/Content.IntegrationTests/PoolManager.Cvars.cs +++ b/Content.IntegrationTests/PoolManager.Cvars.cs @@ -35,7 +35,9 @@ private static readonly (string cvar, string value)[] TestCvars = (CCVars.ConfigPresetDevelopment.Name, "false"), (CCVars.AdminLogsEnabled.Name, "false"), (CCVars.AutosaveEnabled.Name, "false"), - (CVars.NetBufferSize.Name, "0") + (CVars.NetBufferSize.Name, "0"), + (CCVars.InteractionRateLimitCount.Name, "9999999"), + (CCVars.InteractionRateLimitPeriod.Name, "0.1"), }; public static async Task SetupCVars(RobustIntegrationTest.IntegrationInstance instance, PoolSettings settings) diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index 19786d816b..f3dd0d1191 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -53,6 +53,14 @@ public sealed partial class BwoinkSystem : SharedBwoinkSystem private string _onCallUrl = string.Empty; private WebhookData? _onCallData; + private ISawmill _sawmill = default!; + private readonly HttpClient _httpClient = new(); + private string _webhookUrl = string.Empty; + private WebhookData? _webhookData; + + private string _onCallUrl = string.Empty; + private WebhookData? _onCallData; + private ISawmill _sawmill = default!; private readonly HttpClient _httpClient = new(); diff --git a/Content.Server/Chat/Managers/ChatManager.RateLimit.cs b/Content.Server/Chat/Managers/ChatManager.RateLimit.cs index 45e7d2e20d..ccb38166a6 100644 --- a/Content.Server/Chat/Managers/ChatManager.RateLimit.cs +++ b/Content.Server/Chat/Managers/ChatManager.RateLimit.cs @@ -1,6 +1,6 @@ -using Content.Server.Players.RateLimiting; using Content.Shared.CCVar; using Content.Shared.Database; +using Content.Shared.Players.RateLimiting; using Robust.Shared.Player; namespace Content.Server.Chat.Managers; @@ -12,15 +12,13 @@ internal sealed partial class ChatManager private void RegisterRateLimits() { _rateLimitManager.Register(RateLimitKey, - new RateLimitRegistration - { - CVarLimitPeriodLength = CCVars.ChatRateLimitPeriod, - CVarLimitCount = CCVars.ChatRateLimitCount, - CVarAdminAnnounceDelay = CCVars.ChatRateLimitAnnounceAdminsDelay, - PlayerLimitedAction = RateLimitPlayerLimited, - AdminAnnounceAction = RateLimitAlertAdmins, - AdminLogType = LogType.ChatRateLimited, - }); + new RateLimitRegistration(CCVars.ChatRateLimitPeriod, + CCVars.ChatRateLimitCount, + RateLimitPlayerLimited, + CCVars.ChatRateLimitAnnounceAdminsDelay, + RateLimitAlertAdmins, + LogType.ChatRateLimited) + ); } private void RateLimitPlayerLimited(ICommonSession player) @@ -30,8 +28,7 @@ private void RateLimitPlayerLimited(ICommonSession player) private void RateLimitAlertAdmins(ICommonSession player) { - if (_configurationManager.GetCVar(CCVars.ChatRateLimitAnnounceAdmins)) - SendAdminAlert(Loc.GetString("chat-manager-rate-limit-admin-announcement", ("player", player.Name))); + SendAdminAlert(Loc.GetString("chat-manager-rate-limit-admin-announcement", ("player", player.Name))); } public RateLimitStatus HandleRateLimit(ICommonSession player) diff --git a/Content.Server/Chat/Managers/ChatManager.cs b/Content.Server/Chat/Managers/ChatManager.cs index b80daa2c27..b9f96cb06a 100644 --- a/Content.Server/Chat/Managers/ChatManager.cs +++ b/Content.Server/Chat/Managers/ChatManager.cs @@ -12,6 +12,7 @@ using Content.Shared.Chat; using Content.Shared.Database; using Content.Shared.Mind; +using Content.Shared.Players.RateLimiting; using Robust.Shared.Configuration; using Robust.Shared.Network; using Robust.Shared.Player; diff --git a/Content.Server/Chat/Managers/IChatManager.cs b/Content.Server/Chat/Managers/IChatManager.cs index 35059a254e..e1400e3e8d 100644 --- a/Content.Server/Chat/Managers/IChatManager.cs +++ b/Content.Server/Chat/Managers/IChatManager.cs @@ -1,17 +1,14 @@ using System.Diagnostics.CodeAnalysis; -using Content.Server.Players; -using Content.Server.Players.RateLimiting; using Content.Shared.Administration; using Content.Shared.Chat; +using Content.Shared.Players.RateLimiting; using Robust.Shared.Network; using Robust.Shared.Player; namespace Content.Server.Chat.Managers { - public interface IChatManager + public interface IChatManager : ISharedChatManager { - void Initialize(); - /// /// Dispatch a server announcement to every connected player. /// @@ -25,8 +22,7 @@ public interface IChatManager void SendHookOOC(string sender, string message); void SendAdminAnnouncement(string message, AdminFlags? flagBlacklist = null, AdminFlags? flagWhitelist = null); - void SendAdminAlert(string message); - void SendAdminAlert(EntityUid player, string message); + void SendAdminAnnouncementMessage(ICommonSession player, string message, bool suppressLog = true); void ChatMessageToOne(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, INetChannel client, Color? colorOverride = null, bool recordReplay = false, string? audioPath = null, float audioVolume = 0, NetUserId? author = null); diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs index bfc22f2ad2..e2320f7691 100644 --- a/Content.Server/Chat/Systems/ChatSystem.cs +++ b/Content.Server/Chat/Systems/ChatSystem.cs @@ -22,6 +22,7 @@ using Content.Shared.Language.Systems; using Content.Shared.Mobs.Systems; using Content.Shared.Players; +using Content.Shared.Players.RateLimiting; using Content.Shared.Radio; using Content.Shared.Speech; using Robust.Server.Player; diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index 842a8e7276..9274ace66d 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -27,8 +27,10 @@ using Content.Server.Worldgen.Tools; using Content.Shared.Administration.Logs; using Content.Shared.Administration.Managers; +using Content.Shared.Chat; using Content.Shared.Kitchen; using Content.Shared.Players.PlayTimeTracking; +using Content.Shared.Players.RateLimiting; namespace Content.Server.IoC { @@ -37,6 +39,7 @@ internal static class ServerContentIoC public static void Register() { IoCManager.Register(); + IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); @@ -71,6 +74,8 @@ public static void Register() IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs b/Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs index 59f086f9c3..a3b4d4a536 100644 --- a/Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs +++ b/Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs @@ -1,6 +1,7 @@ using System.Runtime.InteropServices; using Content.Server.Administration.Logs; using Content.Shared.Database; +using Content.Shared.Players.RateLimiting; using Robust.Server.Player; using Robust.Shared.Configuration; using Robust.Shared.Enums; @@ -10,26 +11,7 @@ namespace Content.Server.Players.RateLimiting; -/// -/// General-purpose system to rate limit actions taken by clients, such as chat messages. -/// -/// -/// -/// Different categories of rate limits must be registered ahead of time by calling . -/// Once registered, you can simply call to count a rate-limited action for a player. -/// -/// -/// This system is intended for rate limiting player actions over short periods, -/// to ward against spam that can cause technical issues such as admin client load. -/// It should not be used for in-game actions or similar. -/// -/// -/// Rate limits are reset when a client reconnects. -/// This should not be an issue for the reasonably short rate limit periods this system is intended for. -/// -/// -/// -public sealed class PlayerRateLimitManager +public sealed class PlayerRateLimitManager : SharedPlayerRateLimitManager { [Dependency] private readonly IAdminLogManager _adminLog = default!; [Dependency] private readonly IGameTiming _gameTiming = default!; @@ -39,18 +21,7 @@ public sealed class PlayerRateLimitManager private readonly Dictionary _registrations = new(); private readonly Dictionary> _rateLimitData = new(); - /// - /// Count and validate an action performed by a player against rate limits. - /// - /// The player performing the action. - /// The key string that was previously used to register a rate limit category. - /// Whether the action counted should be blocked due to surpassing rate limits or not. - /// - /// is not a connected player - /// OR is not a registered rate limit category. - /// - /// - public RateLimitStatus CountAction(ICommonSession player, string key) + public override RateLimitStatus CountAction(ICommonSession player, string key) { if (player.Status == SessionStatus.Disconnected) throw new ArgumentException("Player is not connected"); @@ -74,7 +45,8 @@ public RateLimitStatus CountAction(ICommonSession player, string key) return RateLimitStatus.Allowed; // Breached rate limits, inform admins if configured. - if (registration.AdminAnnounceDelay is { } cvarAnnounceDelay) + // Negative delays can be used to disable admin announcements. + if (registration.AdminAnnounceDelay is {TotalSeconds: >= 0} cvarAnnounceDelay) { if (datum.NextAdminAnnounce < time) { @@ -85,7 +57,7 @@ public RateLimitStatus CountAction(ICommonSession player, string key) if (!datum.Announced) { - registration.Registration.PlayerLimitedAction(player); + registration.Registration.PlayerLimitedAction?.Invoke(player); _adminLog.Add( registration.Registration.AdminLogType, LogImpact.Medium, @@ -97,17 +69,7 @@ public RateLimitStatus CountAction(ICommonSession player, string key) return RateLimitStatus.Blocked; } - /// - /// Register a new rate limit category. - /// - /// - /// The key string that will be referred to later with . - /// Must be unique and should probably just be a constant somewhere. - /// - /// The data specifying the rate limit's parameters. - /// has already been registered. - /// is invalid. - public void Register(string key, RateLimitRegistration registration) + public override void Register(string key, RateLimitRegistration registration) { if (_registrations.ContainsKey(key)) throw new InvalidOperationException($"Key already registered: {key}"); @@ -135,7 +97,7 @@ public void Register(string key, RateLimitRegistration registration) if (registration.CVarAdminAnnounceDelay != null) { _cfg.OnValueChanged( - registration.CVarLimitCount, + registration.CVarAdminAnnounceDelay, i => data.AdminAnnounceDelay = TimeSpan.FromSeconds(i), invokeImmediately: true); } @@ -143,10 +105,7 @@ public void Register(string key, RateLimitRegistration registration) _registrations.Add(key, data); } - /// - /// Initialize the manager's functionality at game startup. - /// - public void Initialize() + public override void Initialize() { _playerManager.PlayerStatusChanged += PlayerManagerOnPlayerStatusChanged; } @@ -189,66 +148,3 @@ private struct RateLimitDatum public TimeSpan NextAdminAnnounce; } } - -/// -/// Contains all data necessary to register a rate limit with . -/// -public sealed class RateLimitRegistration -{ - /// - /// CVar that controls the period over which the rate limit is counted, measured in seconds. - /// - public required CVarDef CVarLimitPeriodLength { get; init; } - - /// - /// CVar that controls how many actions are allowed in a single rate limit period. - /// - public required CVarDef CVarLimitCount { get; init; } - - /// - /// An action that gets invoked when this rate limit has been breached by a player. - /// - /// - /// This can be used for informing players or taking administrative action. - /// - public required Action PlayerLimitedAction { get; init; } - - /// - /// CVar that controls the minimum delay between admin notifications, measured in seconds. - /// This can be omitted to have no admin notification system. - /// - /// - /// If set, must be set too. - /// - public CVarDef? CVarAdminAnnounceDelay { get; init; } - - /// - /// An action that gets invoked when a rate limit was breached and admins should be notified. - /// - /// - /// If set, must be set too. - /// - public Action? AdminAnnounceAction { get; init; } - - /// - /// Log type used to log rate limit violations to the admin logs system. - /// - public LogType AdminLogType { get; init; } = LogType.RateLimited; -} - -/// -/// Result of a rate-limited operation. -/// -/// -public enum RateLimitStatus : byte -{ - /// - /// The action was not blocked by the rate limit. - /// - Allowed, - - /// - /// The action was blocked by the rate limit. - /// - Blocked, -} diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index 202ad0058e..c6bfbeef85 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -987,8 +987,8 @@ public static readonly CVarDef /// After the period has passed, the count resets. /// /// - public static readonly CVarDef AhelpRateLimitPeriod = - CVarDef.Create("ahelp.rate_limit_period", 2, CVar.SERVERONLY); + public static readonly CVarDef AhelpRateLimitPeriod = + CVarDef.Create("ahelp.rate_limit_period", 2f, CVar.SERVERONLY); /// /// How many ahelp messages are allowed in a single rate limit period. @@ -1949,8 +1949,8 @@ public static readonly CVarDef /// After the period has passed, the count resets. /// /// - public static readonly CVarDef ChatRateLimitPeriod = - CVarDef.Create("chat.rate_limit_period", 2, CVar.SERVERONLY); + public static readonly CVarDef ChatRateLimitPeriod = + CVarDef.Create("chat.rate_limit_period", 2f, CVar.SERVERONLY); /// /// How many chat messages are allowed in a single rate limit period. @@ -1960,19 +1960,12 @@ public static readonly CVarDef /// divided by . /// /// - /// public static readonly CVarDef ChatRateLimitCount = CVarDef.Create("chat.rate_limit_count", 10, CVar.SERVERONLY); /// - /// If true, announce when a player breached chat rate limit to game administrators. - /// - /// - public static readonly CVarDef ChatRateLimitAnnounceAdmins = - CVarDef.Create("chat.rate_limit_announce_admins", true, CVar.SERVERONLY); - - /// - /// Minimum delay (in seconds) between announcements from . + /// Minimum delay (in seconds) between notifying admins about chat message rate limit violations. + /// A negative value disables admin announcements. /// public static readonly CVarDef ChatRateLimitAnnounceAdminsDelay = CVarDef.Create("chat.rate_limit_announce_admins_delay", 15, CVar.SERVERONLY); @@ -2186,6 +2179,34 @@ public static readonly CVarDef public static readonly CVarDef DefaultWalk = CVarDef.Create("control.default_walk", true, CVar.CLIENT | CVar.REPLICATED | CVar.ARCHIVE); + /* + * Interactions + */ + + // The rationale behind the default limit is simply that I can easily get to 7 interactions per second by just + // trying to spam toggle a light switch or lever (though the UseDelay component limits the actual effect of the + // interaction). I don't want to accidentally spam admins with alerts just because somebody is spamming a + // key manually, nor do we want to alert them just because the player is having network issues and the server + // receives multiple interactions at once. But we also want to try catch people with modified clients that spam + // many interactions on the same tick. Hence, a very short period, with a relatively high count. + + /// + /// Maximum number of interactions that a player can perform within seconds + /// + public static readonly CVarDef InteractionRateLimitCount = + CVarDef.Create("interaction.rate_limit_count", 5, CVar.SERVER | CVar.REPLICATED); + + /// + public static readonly CVarDef InteractionRateLimitPeriod = + CVarDef.Create("interaction.rate_limit_period", 0.5f, CVar.SERVER | CVar.REPLICATED); + + /// + /// Minimum delay (in seconds) between notifying admins about interaction rate limit violations. A negative + /// value disables admin announcements. + /// + public static readonly CVarDef InteractionRateLimitAnnounceAdminsDelay = + CVarDef.Create("interaction.rate_limit_announce_admins_delay", 120, CVar.SERVERONLY); + /* * STORAGE */ diff --git a/Content.Shared/Chat/ISharedChatManager.cs b/Content.Shared/Chat/ISharedChatManager.cs new file mode 100644 index 0000000000..39c1d85dd2 --- /dev/null +++ b/Content.Shared/Chat/ISharedChatManager.cs @@ -0,0 +1,8 @@ +namespace Content.Shared.Chat; + +public interface ISharedChatManager +{ + void Initialize(); + void SendAdminAlert(string message); + void SendAdminAlert(EntityUid player, string message); +} diff --git a/Content.Shared/Interaction/SharedInteractionSystem.cs b/Content.Shared/Interaction/SharedInteractionSystem.cs index 3e2506efae..473be07e67 100644 --- a/Content.Shared/Interaction/SharedInteractionSystem.cs +++ b/Content.Shared/Interaction/SharedInteractionSystem.cs @@ -2,6 +2,8 @@ using System.Linq; using Content.Shared.ActionBlocker; using Content.Shared.Administration.Logs; +using Content.Shared.CCVar; +using Content.Shared.Chat; using Content.Shared.CombatMode; using Content.Shared.Database; using Content.Shared.Ghost; @@ -16,6 +18,7 @@ using Content.Shared.Movement.Components; using Content.Shared.Movement.Pulling.Systems; using Content.Shared.Physics; +using Content.Shared.Players.RateLimiting; using Content.Shared.Popups; using Content.Shared.Storage; using Content.Shared.Tag; @@ -24,6 +27,7 @@ using Content.Shared.Verbs; using Content.Shared.Wall; using JetBrains.Annotations; +using Robust.Shared.Configuration; using Robust.Shared.Containers; using Robust.Shared.Input; using Robust.Shared.Input.Binding; @@ -65,6 +69,9 @@ public abstract partial class SharedInteractionSystem : EntitySystem [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly TagSystem _tagSystem = default!; [Dependency] private readonly SharedUserInterfaceSystem _ui = default!; + [Dependency] private readonly SharedPlayerRateLimitManager _rateLimit = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly ISharedChatManager _chat = default!; private EntityQuery _ignoreUiRangeQuery; private EntityQuery _fixtureQuery; @@ -81,8 +88,8 @@ public abstract partial class SharedInteractionSystem : EntitySystem public const float InteractionRange = 1.5f; public const float InteractionRangeSquared = InteractionRange * InteractionRange; - public const float MaxRaycastRange = 100f; + public const string RateLimitKey = "Interaction"; public delegate bool Ignored(EntityUid entity); @@ -124,9 +131,22 @@ public override void Initialize() new PointerInputCmdHandler(HandleTryPullObject)) .Register(); + _rateLimit.Register(RateLimitKey, + new RateLimitRegistration(CCVars.InteractionRateLimitPeriod, + CCVars.InteractionRateLimitCount, + null, + CCVars.InteractionRateLimitAnnounceAdminsDelay, + RateLimitAlertAdmins) + ); + InitializeBlocking(); } + private void RateLimitAlertAdmins(ICommonSession session) + { + _chat.SendAdminAlert(Loc.GetString("interaction-rate-limit-admin-announcement", ("player", session.Name))); + } + public override void Shutdown() { CommandBinds.Unregister(); @@ -1200,8 +1220,11 @@ public bool CanAccessEquipment(EntityUid user, EntityUid target) return InRangeUnobstructed(user, wearer) && _containerSystem.IsInSameOrParentContainer(user, wearer); } - protected bool ValidateClientInput(ICommonSession? session, EntityCoordinates coords, - EntityUid uid, [NotNullWhen(true)] out EntityUid? userEntity) + protected bool ValidateClientInput( + ICommonSession? session, + EntityCoordinates coords, + EntityUid uid, + [NotNullWhen(true)] out EntityUid? userEntity) { userEntity = null; @@ -1231,7 +1254,7 @@ protected bool ValidateClientInput(ICommonSession? session, EntityCoordinates co return false; } - return true; + return _rateLimit.CountAction(session!, RateLimitKey) == RateLimitStatus.Allowed; } /// diff --git a/Content.Shared/Players/RateLimiting/RateLimitRegistration.cs b/Content.Shared/Players/RateLimiting/RateLimitRegistration.cs new file mode 100644 index 0000000000..6bcf15d30b --- /dev/null +++ b/Content.Shared/Players/RateLimiting/RateLimitRegistration.cs @@ -0,0 +1,76 @@ +using Content.Shared.Database; +using Robust.Shared.Configuration; +using Robust.Shared.Player; + +namespace Content.Shared.Players.RateLimiting; + +/// +/// Contains all data necessary to register a rate limit with . +/// +public sealed class RateLimitRegistration( + CVarDef cVarLimitPeriodLength, + CVarDef cVarLimitCount, + Action? playerLimitedAction, + CVarDef? cVarAdminAnnounceDelay = null, + Action? adminAnnounceAction = null, + LogType adminLogType = LogType.RateLimited) +{ + /// + /// CVar that controls the period over which the rate limit is counted, measured in seconds. + /// + public readonly CVarDef CVarLimitPeriodLength = cVarLimitPeriodLength; + + /// + /// CVar that controls how many actions are allowed in a single rate limit period. + /// + public readonly CVarDef CVarLimitCount = cVarLimitCount; + + /// + /// An action that gets invoked when this rate limit has been breached by a player. + /// + /// + /// This can be used for informing players or taking administrative action. + /// + public readonly Action? PlayerLimitedAction = playerLimitedAction; + + /// + /// CVar that controls the minimum delay between admin notifications, measured in seconds. + /// This can be omitted to have no admin notification system. + /// If the cvar is set to 0, there every breach will be reported. + /// If the cvar is set to a negative number, admin announcements are disabled. + /// + /// + /// If set, must be set too. + /// + public readonly CVarDef? CVarAdminAnnounceDelay = cVarAdminAnnounceDelay; + + /// + /// An action that gets invoked when a rate limit was breached and admins should be notified. + /// + /// + /// If set, must be set too. + /// + public readonly Action? AdminAnnounceAction = adminAnnounceAction; + + /// + /// Log type used to log rate limit violations to the admin logs system. + /// + public readonly LogType AdminLogType = adminLogType; +} + +/// +/// Result of a rate-limited operation. +/// +/// +public enum RateLimitStatus : byte +{ + /// + /// The action was not blocked by the rate limit. + /// + Allowed, + + /// + /// The action was blocked by the rate limit. + /// + Blocked, +} diff --git a/Content.Shared/Players/RateLimiting/SharedPlayerRateLimitManager.cs b/Content.Shared/Players/RateLimiting/SharedPlayerRateLimitManager.cs new file mode 100644 index 0000000000..addb1dee37 --- /dev/null +++ b/Content.Shared/Players/RateLimiting/SharedPlayerRateLimitManager.cs @@ -0,0 +1,55 @@ +using Robust.Shared.Player; + +namespace Content.Shared.Players.RateLimiting; + +/// +/// General-purpose system to rate limit actions taken by clients, such as chat messages. +/// +/// +/// +/// Different categories of rate limits must be registered ahead of time by calling . +/// Once registered, you can simply call to count a rate-limited action for a player. +/// +/// +/// This system is intended for rate limiting player actions over short periods, +/// to ward against spam that can cause technical issues such as admin client load. +/// It should not be used for in-game actions or similar. +/// +/// +/// Rate limits are reset when a client reconnects. +/// This should not be an issue for the reasonably short rate limit periods this system is intended for. +/// +/// +/// +public abstract class SharedPlayerRateLimitManager +{ + /// + /// Count and validate an action performed by a player against rate limits. + /// + /// The player performing the action. + /// The key string that was previously used to register a rate limit category. + /// Whether the action counted should be blocked due to surpassing rate limits or not. + /// + /// is not a connected player + /// OR is not a registered rate limit category. + /// + /// + public abstract RateLimitStatus CountAction(ICommonSession player, string key); + + /// + /// Register a new rate limit category. + /// + /// + /// The key string that will be referred to later with . + /// Must be unique and should probably just be a constant somewhere. + /// + /// The data specifying the rate limit's parameters. + /// has already been registered. + /// is invalid. + public abstract void Register(string key, RateLimitRegistration registration); + + /// + /// Initialize the manager's functionality at game startup. + /// + public abstract void Initialize(); +} diff --git a/Resources/Locale/en-US/interaction/interaction-system.ftl b/Resources/Locale/en-US/interaction/interaction-system.ftl index a4c380abca..3c0c3ae8b4 100644 --- a/Resources/Locale/en-US/interaction/interaction-system.ftl +++ b/Resources/Locale/en-US/interaction/interaction-system.ftl @@ -1,2 +1,3 @@ shared-interaction-system-in-range-unobstructed-cannot-reach = You can't reach there! -interaction-system-user-interaction-cannot-reach = You can't reach there! \ No newline at end of file +interaction-system-user-interaction-cannot-reach = You can't reach there! +interaction-rate-limit-admin-announcement = Player { $player } breached interaction rate limits. They may be using macros, auto-clickers, or a modified client. Though they may just be spamming buttons or having network issues. From 67ab5dc02f78573f5a836e1a6ec506468e266f09 Mon Sep 17 00:00:00 2001 From: sleepyyapril Date: Wed, 27 Nov 2024 19:28:55 -0400 Subject: [PATCH 08/11] maaaaow --- Content.Client/Stylesheets/StyleNano.cs | 2 +- .../Administration/Systems/BwoinkSystem.cs | 15 ++++----------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/Content.Client/Stylesheets/StyleNano.cs b/Content.Client/Stylesheets/StyleNano.cs index fd6ad15fe0..67b81c1145 100644 --- a/Content.Client/Stylesheets/StyleNano.cs +++ b/Content.Client/Stylesheets/StyleNano.cs @@ -1640,7 +1640,7 @@ public StyleNano(IResourceCache resCache) : base(resCache) new[] { new StyleProperty(TextureButton.StylePropertyTexture, resCache.GetTexture("/Textures/Interface/Bwoink/un_pinned.png")) - }) + }), // Shitmed Edit Start Element().Class("TargetDollButtonHead") .Pseudo(TextureButton.StylePseudoClassHover) diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index f3dd0d1191..bd80d91d06 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -53,14 +53,6 @@ public sealed partial class BwoinkSystem : SharedBwoinkSystem private string _onCallUrl = string.Empty; private WebhookData? _onCallData; - private ISawmill _sawmill = default!; - private readonly HttpClient _httpClient = new(); - private string _webhookUrl = string.Empty; - private WebhookData? _webhookData; - - private string _onCallUrl = string.Empty; - private WebhookData? _onCallData; - private ISawmill _sawmill = default!; private readonly HttpClient _httpClient = new(); @@ -109,7 +101,7 @@ public override void Initialize() _gameTicker.RunLevel, playedSound: false ); - _maxAdditionalChars = GenerateAHelpMessage(defaultParams).Length; + _maxAdditionalChars = GenerateAHelpMessage(defaultParams).Message.Length; _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; SubscribeLocalEvent(OnGameRunLevelChanged); @@ -118,7 +110,8 @@ public override void Initialize() _rateLimit.Register( RateLimitKey, - new RateLimitRegistration(CCVars.AhelpRateLimitPeriod, + new RateLimitRegistration( + CCVars.AhelpRateLimitPeriod, CCVars.AhelpRateLimitCount, PlayerRateLimitedAction) ); @@ -769,7 +762,7 @@ private void OnBwoinkInternal(BwoinkTextMessage message, NetUserId senderId, Adm overrideMsgText = $"{(message.PlaySound ? "" : "(S) ")}{overrideMsgText}: {escapedText}"; RaiseNetworkEvent(new BwoinkTextMessage(message.UserId, - senderSession.UserId, + senderId, overrideMsgText, playSound: playSound), session.Channel); From 46133dee4ad78bd265a420aea3eccd006d4cebc1 Mon Sep 17 00:00:00 2001 From: ShadowCommander <10494922+ShadowCommander@users.noreply.github.com> Date: Thu, 30 May 2024 23:28:08 -0700 Subject: [PATCH 09/11] Add search filter to the admin menu player tab (#28030) --- .../UI/Bwoink/BwoinkWindow.xaml.cs | 15 ++- .../UI/Tabs/PlayerTab/PlayerTab.xaml | 26 ++--- .../UI/Tabs/PlayerTab/PlayerTab.xaml.cs | 104 +++++++++++++----- .../UI/Tabs/PlayerTab/PlayerTabEntry.xaml | 8 +- .../UI/Tabs/PlayerTab/PlayerTabEntry.xaml.cs | 26 +++-- .../UI/Tabs/PlayerTab/PlayerTabHeader.xaml | 3 +- .../UserInterface/Controls/ListContainer.cs | 39 +++++-- .../Controls/SearchListContainer.cs | 68 ++++++++++++ .../Systems/Admin/AdminUIController.cs | 9 +- .../Locale/en-US/administration/bwoink.ftl | 5 +- .../administration/ui/tabs/player-tab.ftl | 3 + 11 files changed, 226 insertions(+), 80 deletions(-) create mode 100644 Content.Client/UserInterface/Controls/SearchListContainer.cs diff --git a/Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs b/Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs index 7c0a912abd..6f6c1c8f6e 100644 --- a/Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs +++ b/Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs @@ -16,14 +16,17 @@ public BwoinkWindow() Bwoink.ChannelSelector.OnSelectionChanged += sel => { - if (sel is not null) + if (sel is null) { - Title = $"{sel.CharacterName} / {sel.Username}"; + Title = Loc.GetString("bwoink-none-selected"); + return; + } + + Title = $"{sel.CharacterName} / {sel.Username}"; - if (sel.OverallPlaytime != null) - { - Title += $" | {Loc.GetString("generic-playtime-title")}: {sel.PlaytimeString}"; - } + if (sel.OverallPlaytime != null) + { + Title += $" | {Loc.GetString("generic-playtime-title")}: {sel.PlaytimeString}"; } }; diff --git a/Content.Client/Administration/UI/Tabs/PlayerTab/PlayerTab.xaml b/Content.Client/Administration/UI/Tabs/PlayerTab/PlayerTab.xaml index 3071bf8358..25a96df1d3 100644 --- a/Content.Client/Administration/UI/Tabs/PlayerTab/PlayerTab.xaml +++ b/Content.Client/Administration/UI/Tabs/PlayerTab/PlayerTab.xaml @@ -1,21 +1,19 @@  + xmlns:cc="clr-namespace:Content.Client.Administration.UI.CustomControls" + xmlns:co="clr-namespace:Content.Client.UserInterface.Controls"> -