diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index bdabea266a..21b3f2b612 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -7,6 +7,7 @@ using System.Text.Json.Serialization; using Content.Server.Administration.Managers; using Content.Server.GameTicking; using Content.Server.GameTicking.Events; +using Content.Server.Players; using Content.Shared.Administration; using Content.Shared.CCVar; using JetBrains.Annotations; @@ -30,20 +31,27 @@ namespace Content.Server.Administration.Systems private ISawmill _sawmill = default!; private readonly HttpClient _httpClient = new(); private string _webhookUrl = string.Empty; + 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 readonly Dictionary> _messageQueues = new(); private readonly HashSet _processingChannels = new(); - private const ushort MessageMax = 2000; + + // Max embed description length is 4096, according to https://discord.com/developers/docs/resources/channel#embed-object-embed-limits + // Keep small margin, just to be safe + private const ushort DescriptionMax = 4000; private int _maxAdditionalChars; public override void Initialize() { base.Initialize(); _config.OnValueChanged(CCVars.DiscordAHelpWebhook, OnWebhookChanged, true); + _config.OnValueChanged(CCVars.DiscordAHelpFooterIcon, OnFooterIconChanged, true); + _config.OnValueChanged(CCVars.DiscordAHelpAvatar, OnAvatarChanged, true); _config.OnValueChanged(CVars.GameHostName, OnServerNameChanged, true); _sawmill = IoCManager.Resolve().GetSawmill("AHELP"); - _maxAdditionalChars = GenerateAHelpMessage("", "", true, true).Length; + _maxAdditionalChars = GenerateAHelpMessage("", "", true).Length; SubscribeLocalEvent(RoundStarting); } @@ -62,6 +70,7 @@ namespace Content.Server.Administration.Systems { base.Shutdown(); _config.UnsubValueChanged(CCVars.DiscordAHelpWebhook, OnWebhookChanged); + _config.UnsubValueChanged(CCVars.DiscordAHelpFooterIcon, OnFooterIconChanged); _config.UnsubValueChanged(CVars.GameHostName, OnServerNameChanged); } @@ -70,9 +79,19 @@ namespace Content.Server.Administration.Systems _webhookUrl = obj; } + private void OnFooterIconChanged(string url) + { + _footerIconUrl = url; + } + + private void OnAvatarChanged(string url) + { + _avatarUrl = url; + } + private async void ProcessQueue(NetUserId channelId, Queue messages) { - if (!_relayMessages.TryGetValue(channelId, out var oldMessage) || messages.Sum(x => x.Length+2) + oldMessage.content.Length > MessageMax) + if (!_relayMessages.TryGetValue(channelId, out var oldMessage) || messages.Sum(x => x.Length + 2) + oldMessage.messages.Length > DescriptionMax) { var lookup = await _playerLocator.LookupIdAsync(channelId); @@ -83,18 +102,35 @@ namespace Content.Server.Administration.Systems return; } - oldMessage = (string.Empty, lookup.Username, ""); + var characterName = _playerManager.GetPlayerData(channelId).ContentData()?.Mind?.CharacterName; + + oldMessage = (string.Empty, lookup.Username, string.Empty, characterName); } while (messages.TryDequeue(out var message)) { - oldMessage.content += $"\n{message}"; + oldMessage.messages += $"\n{message}"; } - var payload = new WebhookPayload() + var payload = new WebhookPayload { - Username = $"{oldMessage.username} on {_serverName} (round {_gameTicker.RoundId})", - Content = oldMessage.content + Username = $"{oldMessage.username}{(oldMessage.characterName == null ? string.Empty : $" ({oldMessage.characterName})")}", + AvatarUrl = _avatarUrl, + Embeds = new List + { + new Embed + { + Description = oldMessage.messages, + // If no admins are online, set embed color to red. Otherwise green + Color = GetTargetAdmins().Count > 0 ? 0x41F097 : 0xFF0000, + Footer = new EmbedFooter + { + // Limit server name to 1500 characters, in case someone tries to be a little funny + Text = $"{_serverName[..Math.Min(_serverName.Length, 1500)]} (round {_gameTicker.RoundId})", + IconUrl = _footerIconUrl, + }, + }, + }, }; if (oldMessage.id == string.Empty) @@ -105,7 +141,7 @@ 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: {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(channelId); return; } @@ -128,7 +164,7 @@ 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: {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(channelId); return; } @@ -145,11 +181,14 @@ namespace Content.Server.Administration.Systems foreach (var channelId in _messageQueues.Keys.ToArray()) { - if(_processingChannels.Contains(channelId)) continue; + if (_processingChannels.Contains(channelId)) + continue; var queue = _messageQueues[channelId]; _messageQueues.Remove(channelId); - if (queue.Count == 0) continue; + if (queue.Count == 0) + continue; + _processingChannels.Add(channelId); ProcessQueue(channelId, queue); @@ -188,20 +227,20 @@ namespace Content.Server.Administration.Systems LogBwoink(msg); - // Admins w/ AHelp access - var targets = _adminManager.ActiveAdmins - .Where(p => _adminManager.GetAdminData(p)?.HasFlag(AdminFlags.Adminhelp) ?? false) - .Select(p => p.ConnectedClient).ToList(); + var admins = GetTargetAdmins(); - // And involved player - if (_playerManager.TryGetSessionById(message.ChannelId, out var session)) - if (!targets.Contains(session.ConnectedClient)) - targets.Add(session.ConnectedClient); - - foreach (var channel in targets) + // Notify all admins + foreach (var channel in admins) + { RaiseNetworkEvent(msg, channel); + } - var noReceivers = targets.Count == 1; + // Notify player + if (_playerManager.TryGetSessionById(message.ChannelId, out var session)) + { + if (!admins.Contains(session.ConnectedClient)) + RaiseNetworkEvent(msg, session.ConnectedClient); + } var sendsWebhook = _webhookUrl != string.Empty; if (sendsWebhook) @@ -212,52 +251,93 @@ namespace Content.Server.Administration.Systems var str = message.Text; var unameLength = senderSession.Name.Length; - if (unameLength+str.Length+_maxAdditionalChars > MessageMax) + if (unameLength + str.Length + _maxAdditionalChars > DescriptionMax) { - str = str[..(MessageMax - _maxAdditionalChars - unameLength)]; + str = str[..(DescriptionMax - _maxAdditionalChars - unameLength)]; } - _messageQueues[msg.ChannelId].Enqueue(GenerateAHelpMessage(senderSession.Name, str, !personalChannel, noReceivers)); + _messageQueues[msg.ChannelId].Enqueue(GenerateAHelpMessage(senderSession.Name, str, !personalChannel)); } - if (noReceivers) - { - var systemText = sendsWebhook ? - Loc.GetString("bwoink-system-starmute-message-no-other-users-webhook") : - Loc.GetString("bwoink-system-starmute-message-no-other-users"); - var starMuteMsg = new BwoinkTextMessage(message.ChannelId, SystemUserId, systemText); - RaiseNetworkEvent(starMuteMsg, senderSession.ConnectedClient); - } + if (admins.Count != 0) + return; + + // No admin online, let the player know + var systemText = sendsWebhook ? + Loc.GetString("bwoink-system-starmute-message-no-other-users-webhook") : + Loc.GetString("bwoink-system-starmute-message-no-other-users"); + var starMuteMsg = new BwoinkTextMessage(message.ChannelId, SystemUserId, systemText); + RaiseNetworkEvent(starMuteMsg, senderSession.ConnectedClient); } - private string GenerateAHelpMessage(string username, string message, bool admin, bool noReceiver) + // Returns all online admins with AHelp access + private IList GetTargetAdmins() + { + return _adminManager.ActiveAdmins + .Where(p => _adminManager.GetAdminData(p)?.HasFlag(AdminFlags.Adminhelp) ?? false) + .Select(p => p.ConnectedClient) + .ToList(); + } + + private static string GenerateAHelpMessage(string username, string message, bool admin) { var stringbuilder = new StringBuilder(); - if (noReceiver) - stringbuilder.Append(":sos:"); stringbuilder.Append(admin ? ":outbox_tray:" : ":inbox_tray:"); - stringbuilder.Append(" **"); - stringbuilder.Append(username); - stringbuilder.Append(":** "); + stringbuilder.Append($" **{username}:** "); stringbuilder.Append(message); return stringbuilder.ToString(); } + // https://discord.com/developers/docs/resources/channel#message-object-message-structure private struct WebhookPayload { [JsonPropertyName("username")] public string Username { get; set; } = ""; - [JsonPropertyName("content")] - public string Content { get; set; } = ""; + [JsonPropertyName("avatar_url")] + public string AvatarUrl { get; set; } = ""; + + [JsonPropertyName("embeds")] + public List? Embeds { get; set; } = null; [JsonPropertyName("allowed_mentions")] public Dictionary AllowedMentions { get; set; } = new() { - { "parse", Array.Empty() } + { "parse", Array.Empty() }, }; - public WebhookPayload() {} + public WebhookPayload() { } + } + + // https://discord.com/developers/docs/resources/channel#embed-object-embed-structure + private struct Embed + { + [JsonPropertyName("description")] + public string Description { get; set; } = ""; + + [JsonPropertyName("color")] + public int Color { get; set; } = 0; + + [JsonPropertyName("footer")] + public EmbedFooter? Footer { get; set; } = null; + + public Embed() + { + } + } + + // https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure + private struct EmbedFooter + { + [JsonPropertyName("text")] + public string Text { get; set; } = ""; + + [JsonPropertyName("icon_url")] + public string IconUrl { get; set; } = ""; + + public EmbedFooter() + { + } } } } diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index 9d7bfb1969..3157f13944 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -250,9 +250,25 @@ namespace Content.Shared.CCVar * Discord */ + /// + /// URL of the Discord webhook which will relay all ahelp messages. + /// public static readonly CVarDef DiscordAHelpWebhook = CVarDef.Create("discord.ahelp_webhook", string.Empty, CVar.SERVERONLY); + /// + /// The server icon to use in the Discord ahelp embed footer. + /// Valid values are specified at https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure. + /// + public static readonly CVarDef DiscordAHelpFooterIcon = + CVarDef.Create("discord.ahelp_footer_icon", string.Empty, CVar.SERVERONLY); + + /// + /// The avatar to use for the webhook. Should be an URL. + /// + public static readonly CVarDef DiscordAHelpAvatar = + CVarDef.Create("discord.ahelp_avatar", string.Empty, CVar.SERVERONLY); + /* * Suspicion */