From: Repo <47093363+Titian3@users.noreply.github.com> Date: Tue, 30 Jul 2024 08:28:32 +0000 (+1200) Subject: aHelp fixes and improvements (#28639) X-Git-Url: https://git.smokeofanarchy.ru/gitweb.cgi?a=commitdiff_plain;h=a72445c4193fc50974b0d4fd168525abeab2e591;p=space-station-14.git 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 --- 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 0000000000..80c8a20200 Binary files /dev/null and b/Resources/Textures/Interface/Bwoink/pinned.png differ diff --git a/Resources/Textures/Interface/Bwoink/pinned2.png b/Resources/Textures/Interface/Bwoink/pinned2.png new file mode 100644 index 0000000000..11c4b62f12 Binary files /dev/null and b/Resources/Textures/Interface/Bwoink/pinned2.png differ diff --git a/Resources/Textures/Interface/Bwoink/un_pinned.png b/Resources/Textures/Interface/Bwoink/un_pinned.png new file mode 100644 index 0000000000..26ef8b4818 Binary files /dev/null and b/Resources/Textures/Interface/Bwoink/un_pinned.png differ