]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
aHelp fixes and improvements (#28639)
authorRepo <47093363+Titian3@users.noreply.github.com>
Tue, 30 Jul 2024 08:28:32 +0000 (20:28 +1200)
committerGitHub <noreply@github.com>
Tue, 30 Jul 2024 08:28:32 +0000 (18:28 +1000)
* 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 <comedian_vs_clown@hotmail.com>
Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs
Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml.cs
Content.Client/Administration/UI/CustomControls/PlayerListEntry.xaml [new file with mode: 0644]
Content.Client/Administration/UI/CustomControls/PlayerListEntry.xaml.cs [new file with mode: 0644]
Content.Client/Stylesheets/StyleNano.cs
Content.Server/Administration/Systems/BwoinkSystem.cs
Content.Shared/Administration/PlayerInfo.cs
Resources/Locale/en-US/administration/bwoink.ftl
Resources/Textures/Interface/Bwoink/pinned.png [new file with mode: 0644]
Resources/Textures/Interface/Bwoink/pinned2.png [new file with mode: 0644]
Resources/Textures/Interface/Bwoink/un_pinned.png [new file with mode: 0644]

index 30f9d24df1df31357a312e881edb1cc7d2d9d780..e8653843c742b944d172b869cc2afe33bfb93835 100644 (file)
@@ -30,7 +30,11 @@ namespace Content.Client.Administration.UI.Bwoink
                 }
             };
 
-            OnOpen += () => Bwoink.PopulateList();
+            OnOpen += () =>
+            {
+                Bwoink.ChannelSelector.StopFiltering();
+                Bwoink.PopulateList();
+            };
         }
     }
 }
index 12522d552d71a898aeb5c469bcf0bd6972a76ff3..b09cd727ef8c78678323e0c0b5a51e95cd3e6dff 100644 (file)
@@ -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<PlayerInfo> _playerList = new();
-        private readonly List<PlayerInfo> _sortedPlayerList = new();
+    private readonly IEntityManager _entManager;
+    private readonly IUserInterfaceManager _uiManager;
+    
+    private PlayerInfo? _selectedPlayer;
 
-        public event Action<PlayerInfo?>? OnSelectionChanged;
-        public IReadOnlyList<PlayerInfo> PlayerInfo => _playerList;
+    private List<PlayerInfo> _playerList = new();
+    private readonly List<PlayerInfo> _sortedPlayerList = new();
 
-        public Func<PlayerInfo, string, string>? OverrideText;
-        public Comparison<PlayerInfo>? Comparison;
+    public Comparison<PlayerInfo>? Comparison;
+    public Func<PlayerInfo, string, string>? OverrideText;
 
-        private IEntityManager _entManager;
-        private IUserInterfaceManager _uiManager;
+    public PlayerListControl()
+    {
+        _entManager = IoCManager.Resolve<IEntityManager>();
+        _uiManager = IoCManager.Resolve<IUserInterfaceManager>();
+        _adminSystem = _entManager.System<AdminSystem>();
+        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> PlayerInfo => _playerList;
 
-        public PlayerListControl()
-        {
-            _entManager = IoCManager.Resolve<IEntityManager>();
-            _uiManager = IoCManager.Resolve<IUserInterfaceManager>();
-            _adminSystem = _entManager.System<AdminSystem>();
-            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<PlayerInfo?>? 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<VerbMenuUIController>().OpenVerbMenu(selectedPlayer.NetEntity.Value, true);
-            args.Handle();
-        }
+        _uiManager.GetUIController<VerbMenuUIController>().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<PlayerInfo>? 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<PlayerInfo>? 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 (file)
index 0000000..af13ccc
--- /dev/null
@@ -0,0 +1,6 @@
+<BoxContainer xmlns="https://spacestation14.io"
+              Orientation="Horizontal" HorizontalExpand="true">
+    <Label Name="PlayerEntryLabel" Text="" ClipText="True" HorizontalExpand="True" />
+    <TextureButton Name="PlayerEntryPinButton"
+                   HorizontalAlignment="Right" />
+</BoxContainer>
diff --git a/Content.Client/Administration/UI/CustomControls/PlayerListEntry.xaml.cs b/Content.Client/Administration/UI/CustomControls/PlayerListEntry.xaml.cs
new file mode 100644 (file)
index 0000000..cd6a56e
--- /dev/null
@@ -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<PlayerInfo>? OnPinStatusChanged;
+
+    public void Setup(PlayerInfo info, Func<PlayerInfo, string, string>? overrideText)
+    {
+        Update(info, overrideText);
+        PlayerEntryPinButton.OnPressed += HandlePinButtonPressed(info);
+    }
+
+    private Action<BaseButton.ButtonEventArgs> HandlePinButtonPressed(PlayerInfo info)
+    {
+        return args =>
+        {
+            info.IsPinned = !info.IsPinned;
+            UpdatePinButtonTexture(info.IsPinned);
+            OnPinStatusChanged?.Invoke(info);
+        };
+    }
+
+    private void Update(PlayerInfo info, Func<PlayerInfo, string, string>? 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);
+        }
+    }
+}
index dba48a119da9c4c3aba192f3dff3c5f99259c568..0c04a550597e370a0ebffb4cfebccc412f3d7e28 100644 (file)
@@ -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());
         }
     }
index 0a797aa02a8d64b6c0f799ec2b1939240258c5a1..670940317799f8952bb740690a1713e9ef4b452b 100644 (file)
@@ -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<NetUserId, (string? id, string username, string description, string? characterName, GameRunLevel lastRunLevel)> _relayMessages = new();
+
+        private readonly
+            Dictionary<NetUserId, (string? id, string username, string description, string? characterName, GameRunLevel
+                lastRunLevel)> _relayMessages = new();
+
         private Dictionary<NetUserId, string> _oldMessageIds = new();
         private readonly Dictionary<NetUserId, Queue<string>> _messageQueues = new();
         private readonly HashSet<NetUserId> _processingChannels = new();
@@ -69,6 +76,7 @@ namespace Content.Server.Administration.Systems
         private const string TooLongText = "... **(too long)**";
 
         private int _maxAdditionalChars;
+        private readonly Dictionary<NetUserId, DateTime> _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<ILogManager>().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<GameRunLevelChangedEvent>(OnGameRunLevelChanged);
             SubscribeNetworkEvent<BwoinkClientTypingUpdated>(OnClientTypingUpdated);
+            SubscribeLocalEvent<RoundRestartCleanupEvent>(_ => _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<string>();
+                //
+                // 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<INetChannel> 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,
+    }
+}
index 93f1aa0b39313766c43bfca97b3f23bd2f91efda..ed54d57bbef3dbac58fc48e878a7235081710804 100644 (file)
@@ -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");
 
index 3a92f58ad18bc18efc67dd51dc9806f1fb9abd5b..e43932dc1d39cbcf1a12c1d8c2ef289cee06b3f6 100644 (file)
@@ -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 (file)
index 0000000..80c8a20
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 (file)
index 0000000..11c4b62
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 (file)
index 0000000..26ef8b4
Binary files /dev/null and b/Resources/Textures/Interface/Bwoink/un_pinned.png differ