From a72445c4193fc50974b0d4fd168525abeab2e591 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] 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 | 235 ++++++++-------- .../UI/CustomControls/PlayerListEntry.xaml | 6 + .../UI/CustomControls/PlayerListEntry.xaml.cs | 58 ++++ Content.Client/Stylesheets/StyleNano.cs | 20 ++ .../Administration/Systems/BwoinkSystem.cs | 265 ++++++++++++++++-- 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, 448 insertions(+), 147 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 30f9d24df1..e8653843c7 100644 --- a/Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs +++ b/Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs @@ -30,7 +30,11 @@ namespace Content.Client.Administration.UI.Bwoink } }; - 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 12522d552d..b09cd727ef 100644 --- a/Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml.cs +++ b/Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml.cs @@ -4,154 +4,155 @@ using Content.Client.UserInterface.Controls; 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; - PlayerListContainer.NoItemSelected += PlayerListNoItemSelected; - 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 PlayerListNoItemSelected() - { - _selectedPlayer = null; - OnSelectionChanged?.Invoke(null); - } + private void PlayerListNoItemSelected() + { + _selectedPlayer = null; + OnSelectionChanged?.Invoke(null); + } - private void PlayerListItemPressed(BaseButton.ButtonEventArgs? args, ListData? data) - { - if (args == null || data is not PlayerListData {Info: var selectedPlayer}) - return; + private void PlayerListItemPressed(BaseButton.ButtonEventArgs? args, ListData? data) + { + if (args == null || data is not PlayerListData { Info: var selectedPlayer }) + return; - if (selectedPlayer == _selectedPlayer) - return; + if (selectedPlayer == _selectedPlayer) + return; - if (args.Event.Function != EngineKeyFunctions.UIClick) - return; + if (args.Event.Function != EngineKeyFunctions.UIClick) + return; - OnSelectionChanged?.Invoke(selectedPlayer); - _selectedPlayer = selectedPlayer; + OnSelectionChanged?.Invoke(selectedPlayer); + _selectedPlayer = selectedPlayer; - // 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); - } + // 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); + } - private void PlayerListItemKeyBindDown(GUIBoundKeyEventArgs? args, ListData? data) - { - if (args == null || data is not PlayerListData { Info: var selectedPlayer }) - return; + private void PlayerListItemKeyBindDown(GUIBoundKeyEventArgs? args, ListData? data) + { + if (args == null || data is not PlayerListData { Info: var selectedPlayer }) + return; - if (args.Function != EngineKeyFunctions.UIRightClick || selectedPlayer.NetEntity == null) - return; + if (args.Function != EngineKeyFunctions.UIRightClick || selectedPlayer.NetEntity == null) + return; - _uiManager.GetUIController().OpenVerbMenu(selectedPlayer.NetEntity.Value, true); - args.Handle(); - } + _uiManager.GetUIController().OpenVerbMenu(selectedPlayer.NetEntity.Value, true); + args.Handle(); + } - public void StopFiltering() - { - FilterLineEdit.Text = string.Empty; - } + public void StopFiltering() + { + FilterLineEdit.Text = string.Empty; + } - private void FilterList() + 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 dba48a119d..0c04a55059 100644 --- a/Content.Client/Stylesheets/StyleNano.cs +++ b/Content.Client/Stylesheets/StyleNano.cs @@ -151,6 +151,11 @@ namespace Content.Client.Stylesheets 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) @@ -1608,6 +1613,21 @@ namespace Content.Client.Stylesheets { 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")) + }) }).ToList()); } } diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index 0a797aa02a..6709403177 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -7,11 +7,13 @@ using System.Text.RegularExpressions; 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 JetBrains.Annotations; using Robust.Server.Player; @@ -38,6 +40,7 @@ namespace Content.Server.Administration.Systems [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://discord\.com/api/webhooks/(\d+)/((?!.*/).*)$")] @@ -50,7 +53,11 @@ namespace Content.Server.Administration.Systems 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 HashSet _processingChannels = new(); @@ -69,6 +76,7 @@ namespace Content.Server.Administration.Systems private const string TooLongText = "... **(too long)**"; private int _maxAdditionalChars; + private readonly Dictionary _activeConversations = new(); public override void Initialize() { @@ -79,13 +87,22 @@ namespace Content.Server.Administration.Systems 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).Length; _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; SubscribeLocalEvent(OnGameRunLevelChanged); SubscribeNetworkEvent(OnClientTypingUpdated); + SubscribeLocalEvent(_ => _activeConversations.Clear()); - _rateLimit.Register( + _rateLimit.Register( RateLimitKey, new RateLimitRegistration { @@ -107,14 +124,129 @@ namespace Content.Server.Administration.Systems _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 @@ -209,7 +341,8 @@ namespace Content.Server.Administration.Systems 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}"); + _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; } @@ -233,7 +366,7 @@ namespace Content.Server.Administration.Systems // 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; + + existingEmbed.description.Length > DescriptionMax; // If there is no existing embed, or it is getting too long, we create a new embed if (!exists || tooLong) @@ -242,7 +375,8 @@ namespace Content.Server.Administration.Systems 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; } @@ -254,11 +388,13 @@ namespace Content.Server.Administration.Systems { 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"; } } @@ -274,7 +410,8 @@ namespace Content.Server.Administration.Systems 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; @@ -290,7 +427,9 @@ namespace Content.Server.Administration.Systems existingEmbed.description += $"\n{message}"; } - var payload = GeneratePayload(existingEmbed.description, existingEmbed.username, existingEmbed.characterName); + var payload = GeneratePayload(existingEmbed.description, + existingEmbed.username, + existingEmbed.characterName); // If there is no existing embed, create a new one // Otherwise patch (edit) it @@ -302,7 +441,8 @@ namespace Content.Server.Administration.Systems 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; } @@ -310,7 +450,8 @@ namespace Content.Server.Administration.Systems 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; } @@ -325,7 +466,8 @@ namespace Content.Server.Administration.Systems 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; } @@ -355,7 +497,8 @@ namespace Content.Server.Administration.Systems : $"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 @@ -401,6 +544,7 @@ namespace Content.Server.Administration.Systems protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySessionEventArgs eventArgs) { base.OnBwoinkTextMessage(message, eventArgs); + _activeConversations[message.UserId] = DateTime.Now; var senderSession = eventArgs.SenderSession; // TODO: Sanitize text? @@ -422,7 +566,9 @@ namespace Content.Server.Administration.Systems string bwoinkText; - 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]{senderSession.Name}[/color]"; } @@ -461,7 +607,9 @@ namespace Content.Server.Administration.Systems { 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]"; } @@ -476,7 +624,11 @@ namespace Content.Server.Administration.Systems overrideMsgText = $"{(message.PlaySound ? "" : "(S) ")}{overrideMsgText}: {escapedText}"; - RaiseNetworkEvent(new BwoinkTextMessage(message.UserId, senderSession.UserId, overrideMsgText, playSound: playSound), session.Channel); + RaiseNetworkEvent(new BwoinkTextMessage(message.UserId, + senderSession.UserId, + overrideMsgText, + playSound: playSound), + session.Channel); } else RaiseNetworkEvent(msg, session.Channel); @@ -496,8 +648,18 @@ namespace Content.Server.Administration.Systems { 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( + senderSession.Name, + str, + !personalChannel, + _gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"), + _gameTicker.RunLevel, + playedSound: playSound, + noReceivers: nonAfkAdmins.Count == 0 + ); + _messageQueues[msg.UserId].Enqueue(GenerateAHelpMessage(messageParams)); } if (admins.Count != 0 || sendsWebhook) @@ -512,7 +674,8 @@ namespace Content.Server.Administration.Systems 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(); } @@ -525,25 +688,69 @@ namespace Content.Server.Administration.Systems .ToList(); } - private static string GenerateAHelpMessage(string username, string message, bool admin, string roundTime, GameRunLevel roundState, bool playedSound, bool noReceivers = false) + private static string 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)**"); - stringbuilder.Append($" **{username}:** "); - stringbuilder.Append(message); + + if (parameters.Icon == null) + stringbuilder.Append($" **{parameters.Username}:** "); + else + stringbuilder.Append($" **{parameters.Username}** "); + stringbuilder.Append(parameters.Message); return stringbuilder.ToString(); } } -} + 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 string? Icon { get; set; } + + public AHelpMessageParams( + string username, + string message, + bool isAdmin, + string roundTime, + GameRunLevel roundState, + bool playedSound, + bool noReceivers = false, + string? icon = null) + { + Username = username; + Message = message; + IsAdmin = isAdmin; + RoundTime = roundTime; + RoundState = roundState; + PlayedSound = playedSound; + NoReceivers = noReceivers; + Icon = icon; + } + } + + public enum PlayerStatusType + { + Connected, + Disconnected, + Banned, + } +} 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 @@ namespace Content.Shared.Administration { 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 -- 2.52.0