diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index 1efc0a9d56..7a47755db9 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -47,20 +47,23 @@ namespace Content.Server.Administration.Systems [GeneratedRegex(@"^https://discord\.com/api/webhooks/(\d+)/((?!.*/).*)$")] private static partial Regex DiscordRegex(); - private ISawmill _sawmill = default!; - private readonly HttpClient _httpClient = new(); private string _webhookUrl = string.Empty; private WebhookData? _webhookData; + + private string _onCallUrl = string.Empty; + private WebhookData? _onCallData; + + private ISawmill _sawmill = default!; + private readonly HttpClient _httpClient = new(); + private string _footerIconUrl = string.Empty; private string _avatarUrl = string.Empty; private string _serverName = string.Empty; - private readonly - Dictionary _relayMessages = new(); + private readonly Dictionary _relayMessages = new(); private Dictionary _oldMessageIds = new(); - private readonly Dictionary> _messageQueues = new(); + private readonly Dictionary> _messageQueues = new(); private readonly HashSet _processingChannels = new(); private readonly Dictionary _typingUpdateTimestamps = new(); private string _overrideClientName = string.Empty; @@ -82,12 +85,16 @@ namespace Content.Server.Administration.Systems public override void Initialize() { base.Initialize(); + + Subs.CVar(_config, CCVars.DiscordOnCallWebhook, OnCallChanged, true); + Subs.CVar(_config, CCVars.DiscordAHelpWebhook, OnWebhookChanged, true); Subs.CVar(_config, CCVars.DiscordAHelpFooterIcon, OnFooterIconChanged, true); Subs.CVar(_config, CCVars.DiscordAHelpAvatar, OnAvatarChanged, true); Subs.CVar(_config, CVars.GameHostName, OnServerNameChanged, true); Subs.CVar(_config, CCVars.AdminAhelpOverrideClientName, OnOverrideChanged, true); _sawmill = IoCManager.Resolve().GetSawmill("AHELP"); + var defaultParams = new AHelpMessageParams( string.Empty, string.Empty, @@ -96,7 +103,7 @@ namespace Content.Server.Administration.Systems _gameTicker.RunLevel, playedSound: false ); - _maxAdditionalChars = GenerateAHelpMessage(defaultParams).Length; + _maxAdditionalChars = GenerateAHelpMessage(defaultParams).Message.Length; _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; SubscribeLocalEvent(OnGameRunLevelChanged); @@ -111,6 +118,33 @@ namespace Content.Server.Administration.Systems ); } + private async void OnCallChanged(string url) + { + _onCallUrl = url; + + if (url == string.Empty) + return; + + var match = DiscordRegex().Match(url); + + if (!match.Success) + { + Log.Error("On call URL does not appear to be valid."); + return; + } + + if (match.Groups.Count <= 2) + { + Log.Error("Could not get webhook ID or token for on call URL."); + return; + } + + var webhookId = match.Groups[1].Value; + var webhookToken = match.Groups[2].Value; + + _onCallData = await GetWebhookData(webhookId, webhookToken); + } + private void PlayerRateLimitedAction(ICommonSession obj) { RaiseNetworkEvent( @@ -259,13 +293,13 @@ namespace Content.Server.Administration.Systems // Store the Discord message IDs of the previous round _oldMessageIds = new Dictionary(); - foreach (var message in _relayMessages) + foreach (var (user, interaction) in _relayMessages) { - var id = message.Value.id; + var id = interaction.Id; if (id == null) return; - _oldMessageIds[message.Key] = id; + _oldMessageIds[user] = id; } _relayMessages.Clear(); @@ -330,10 +364,10 @@ namespace Content.Server.Administration.Systems var webhookToken = match.Groups[2].Value; // Fire and forget - await SetWebhookData(webhookId, webhookToken); + _webhookData = await GetWebhookData(webhookId, webhookToken); } - private async Task SetWebhookData(string id, string token) + private async Task GetWebhookData(string id, string token) { var response = await _httpClient.GetAsync($"https://discord.com/api/v10/webhooks/{id}/{token}"); @@ -342,10 +376,10 @@ namespace Content.Server.Administration.Systems { _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; + return null; } - _webhookData = JsonSerializer.Deserialize(content); + return JsonSerializer.Deserialize(content); } private void OnFooterIconChanged(string url) @@ -358,14 +392,14 @@ namespace Content.Server.Administration.Systems _avatarUrl = url; } - private async void ProcessQueue(NetUserId userId, Queue messages) + private async void ProcessQueue(NetUserId userId, Queue messages) { // Whether an embed already exists for this player var exists = _relayMessages.TryGetValue(userId, out var existingEmbed); // Whether the message will become too long after adding these new messages - var tooLong = exists && messages.Sum(msg => Math.Min(msg.Length, MessageLengthCap) + "\n".Length) - + existingEmbed.description.Length > DescriptionMax; + var tooLong = exists && messages.Sum(msg => Math.Min(msg.Message.Length, MessageLengthCap) + "\n".Length) + + existingEmbed?.Description.Length > DescriptionMax; // If there is no existing embed, or it is getting too long, we create a new embed if (!exists || tooLong) @@ -385,10 +419,10 @@ namespace Content.Server.Administration.Systems // If we have all the data required, we can link to the embed of the previous round or embed that was too long if (_webhookData is { GuildId: { } guildId, ChannelId: { } channelId }) { - if (tooLong && existingEmbed.id != null) + if (tooLong && existingEmbed?.Id != null) { linkToPrevious = - $"**[Go to previous embed of this round](https://discord.com/channels/{guildId}/{channelId}/{existingEmbed.id})**\n"; + $"**[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)) { @@ -398,13 +432,22 @@ namespace Content.Server.Administration.Systems } var characterName = _minds.GetCharacterName(userId); - existingEmbed = (null, lookup.Username, linkToPrevious, characterName, _gameTicker.RunLevel); + existingEmbed = new DiscordRelayInteraction() + { + Id = null, + CharacterName = characterName, + Description = linkToPrevious, + Username = lookup.Username, + LastRunLevel = _gameTicker.RunLevel, + }; + + _relayMessages[userId] = existingEmbed; } // Previous message was in another RunLevel, so show that in the embed - if (existingEmbed.lastRunLevel != _gameTicker.RunLevel) + if (existingEmbed!.LastRunLevel != _gameTicker.RunLevel) { - existingEmbed.description += _gameTicker.RunLevel switch + existingEmbed.Description += _gameTicker.RunLevel switch { GameRunLevel.PreRoundLobby => "\n\n:arrow_forward: _**Pre-round lobby started**_\n", GameRunLevel.InRound => "\n\n:arrow_forward: _**Round started**_\n", @@ -413,26 +456,35 @@ namespace Content.Server.Administration.Systems $"{_gameTicker.RunLevel} was not matched."), }; - existingEmbed.lastRunLevel = _gameTicker.RunLevel; + existingEmbed.LastRunLevel = _gameTicker.RunLevel; } + // If last message of the new batch is SOS then relay it to on-call. + // ... as long as it hasn't been relayed already. + var discordMention = messages.Last(); + var onCallRelay = !discordMention.Receivers && !existingEmbed.OnCall; + // Add available messages to the embed description while (messages.TryDequeue(out var message)) { - // In case someone thinks they're funny - if (message.Length > MessageLengthCap) - message = message[..(MessageLengthCap - TooLongText.Length)] + TooLongText; + string text; - existingEmbed.description += $"\n{message}"; + // In case someone thinks they're funny + if (message.Message.Length > MessageLengthCap) + text = message.Message[..(MessageLengthCap - TooLongText.Length)] + TooLongText; + else + text = message.Message; + + existingEmbed.Description += $"\n{text}"; } - 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 - if (existingEmbed.id == null) + if (existingEmbed.Id == null) { var request = await _httpClient.PostAsync($"{_webhookUrl}?wait=true", new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); @@ -455,11 +507,11 @@ namespace Content.Server.Administration.Systems return; } - existingEmbed.id = id.ToString(); + existingEmbed.Id = id.ToString(); } else { - var request = await _httpClient.PatchAsync($"{_webhookUrl}/messages/{existingEmbed.id}", + var request = await _httpClient.PatchAsync($"{_webhookUrl}/messages/{existingEmbed.Id}", new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); if (!request.IsSuccessStatusCode) @@ -474,6 +526,43 @@ namespace Content.Server.Administration.Systems _relayMessages[userId] = existingEmbed; + // Actually do the on call relay last, we just need to grab it before we dequeue every message above. + if (onCallRelay && + _onCallData != null) + { + existingEmbed.OnCall = true; + var roleMention = _config.GetCVar(CCVars.DiscordAhelpMention); + + if (!string.IsNullOrEmpty(roleMention)) + { + var message = new StringBuilder(); + message.AppendLine($"<@&{roleMention}>"); + message.AppendLine("Unanswered SOS"); + + // Need webhook data to get the correct link for that channel rather than on-call data. + if (_webhookData is { GuildId: { } guildId, ChannelId: { } channelId }) + { + message.AppendLine( + $"**[Go to ahelp](https://discord.com/channels/{guildId}/{channelId}/{existingEmbed.Id})**"); + } + + payload = GeneratePayload(message.ToString(), existingEmbed.Username, existingEmbed.CharacterName); + + var request = await _httpClient.PostAsync($"{_onCallUrl}?wait=true", + new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); + + var content = await request.Content.ReadAsStringAsync(); + if (!request.IsSuccessStatusCode) + { + _sawmill.Log(LogLevel.Error, $"Discord returned bad status code when posting relay message (perhaps the message is too long?): {request.StatusCode}\nResponse: {content}"); + } + } + } + else + { + existingEmbed.OnCall = false; + } + _processingChannels.Remove(userId); } @@ -652,7 +741,7 @@ namespace Content.Server.Administration.Systems if (sendsWebhook) { if (!_messageQueues.ContainsKey(msg.UserId)) - _messageQueues[msg.UserId] = new Queue(); + _messageQueues[msg.UserId] = new Queue(); var str = message.Text; var unameLength = senderSession.Name.Length; @@ -701,7 +790,7 @@ namespace Content.Server.Administration.Systems .ToList(); } - private static string GenerateAHelpMessage(AHelpMessageParams parameters) + private static DiscordRelayedData GenerateAHelpMessage(AHelpMessageParams parameters) { var stringbuilder = new StringBuilder(); @@ -718,13 +807,57 @@ namespace Content.Server.Administration.Systems stringbuilder.Append($" **{parameters.RoundTime}**"); if (!parameters.PlayedSound) stringbuilder.Append(" **(S)**"); - if (parameters.Icon == null) stringbuilder.Append($" **{parameters.Username}:** "); else stringbuilder.Append($" **{parameters.Username}** "); stringbuilder.Append(parameters.Message); - return stringbuilder.ToString(); + + return new DiscordRelayedData() + { + Receivers = !parameters.NoReceivers, + Message = stringbuilder.ToString(), + }; + } + + private record struct DiscordRelayedData + { + /// + /// Was anyone online to receive it. + /// + public bool Receivers; + + /// + /// What's the payload to send to discord. + /// + public string Message; + } + + /// + /// Class specifically for holding information regarding existing Discord embeds + /// + private sealed class DiscordRelayInteraction + { + public string? Id; + + public string Username = String.Empty; + + public string? CharacterName; + + /// + /// Contents for the discord message. + /// + public string Description = string.Empty; + + /// + /// Run level of the last interaction. If different we'll link to the last Id. + /// + public GameRunLevel LastRunLevel; + + /// + /// Did we relay this interaction to OnCall previously. + /// + public bool OnCall; } } diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index 81a0668bdb..83068b5262 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -461,6 +461,18 @@ namespace Content.Shared.CCVar * Discord */ + /// + /// The role that will get mentioned if a new SOS ahelp comes in. + /// + public static readonly CVarDef DiscordAhelpMention = + CVarDef.Create("discord.on_call_ping", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL); + + /// + /// URL of the discord webhook to relay unanswered ahelp messages. + /// + public static readonly CVarDef DiscordOnCallWebhook = + CVarDef.Create("discord.on_call_webhook", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL); + /// /// URL of the Discord webhook which will relay all ahelp messages. ///