diff --git a/Content.Server/Administration/ServerApi.cs b/Content.Server/Administration/ServerApi.cs index f1f09d4b50..b592b9322d 100644 --- a/Content.Server/Administration/ServerApi.cs +++ b/Content.Server/Administration/ServerApi.cs @@ -7,12 +7,14 @@ using System.Text.Json.Nodes; using System.Threading.Tasks; using Content.Server.Administration.Systems; +using Content.Server.Administration.Managers; using Content.Server.GameTicking; using Content.Server.GameTicking.Presets; using Content.Server.GameTicking.Rules.Components; using Content.Server.Maps; using Content.Server.RoundEnd; using Content.Shared.Administration.Managers; +using Content.Shared.Administration; using Content.Shared.CCVar; using Content.Shared.GameTicking.Components; using Content.Shared.Prototypes; @@ -48,7 +50,7 @@ public sealed partial class ServerApi : IPostInjectInit [Dependency] private readonly IStatusHost _statusHost = default!; [Dependency] private readonly IConfigurationManager _config = default!; [Dependency] private readonly ISharedPlayerManager _playerManager = default!; - [Dependency] private readonly ISharedAdminManager _adminManager = default!; + [Dependency] private readonly IAdminManager _adminManager = default!; // Frontier: ISharedAdminManager [Dependency] private readonly IGameMapManager _gameMapManager = default!; [Dependency] private readonly IServerNetManager _netManager = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; @@ -81,6 +83,8 @@ void IPostInjectInit.PostInject() RegisterActorHandler(HttpMethod.Post, "/admin/actions/force_preset", ActionForcePreset); RegisterActorHandler(HttpMethod.Post, "/admin/actions/set_motd", ActionForceMotd); RegisterActorHandler(HttpMethod.Patch, "/admin/actions/panic_bunker", ActionPanicPunker); + + RegisterHandler(HttpMethod.Post, "/admin/actions/send_bwoink", ActionSendBwoink); // Frontier - Discord Ahelp Reply } public void Initialize() @@ -393,6 +397,40 @@ await RunOnMainThread(async () => _sawmill.Info($"Forced instant round restart by {FormatLogActor(actor)}"); await RespondOk(context); }); + } + #endregion + + #region Frontier + // Creating a region here incase more actions are added in the future + + private async Task ActionSendBwoink(IStatusHandlerContext context) + { + var body = await ReadJson(context); + if (body == null) + return; + + await RunOnMainThread(async () => + { + // Player not online or wrong Guid + if (!_playerManager.TryGetSessionById(new NetUserId(body.Guid), out var player)) + { + await RespondError( + context, + ErrorCode.PlayerNotFound, + HttpStatusCode.UnprocessableContent, + "Player not found"); + return; + } + + var serverBwoinkSystem = _entitySystemManager.GetEntitySystem(); + var message = new SharedBwoinkSystem.BwoinkTextMessage(player.UserId, SharedBwoinkSystem.SystemUserId, body.Text); + serverBwoinkSystem.OnWebhookBwoinkTextMessage(message, body); + + // Respond with OK + await RespondOk(context); + }); + + } #endregion @@ -631,6 +669,15 @@ private sealed class MotdActionBody public required string Motd { get; init; } } + public sealed class BwoinkActionBody + { + public required string Text { get; init; } + public required string Username { get; init; } + public required Guid Guid { get; init; } + public bool UserOnly { get; init; } + public required bool WebhookUpdate { get; init; } + } + #endregion #region Responses diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index 4358b7e387..11e1fd8e74 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -44,7 +44,7 @@ public sealed partial class BwoinkSystem : SharedBwoinkSystem [Dependency] private readonly IServerDbManager _dbManager = default!; [Dependency] private readonly PlayerRateLimitManager _rateLimit = default!; - [GeneratedRegex(@"^https://discord\.com/api/webhooks/(\d+)/((?!.*/).*)$")] + [GeneratedRegex(@"^https://(?:(?:canary|ptb)\.)?discord\.com/api/webhooks/(\d+)/((?!.*/).*)$")] private static partial Regex DiscordRegex(); private string _webhookUrl = string.Empty; @@ -142,7 +142,7 @@ private async void OnCallChanged(string url) var webhookId = match.Groups[1].Value; var webhookToken = match.Groups[2].Value; - _onCallData = await GetWebhookData(webhookId, webhookToken); + _onCallData = await GetWebhookData(url); } private void PlayerRateLimitedAction(ICommonSession obj) @@ -351,6 +351,7 @@ private async void OnWebhookChanged(string url) { // TODO: Ideally, CVar validation during setting should be better integrated Log.Warning("Webhook URL does not appear to be valid. Using anyways..."); + await GetWebhookData(url); // Frontier - Support for Custom URLS, we still want to see if theres Webhook data available return; } @@ -360,22 +361,19 @@ private async void OnWebhookChanged(string url) return; } - var webhookId = match.Groups[1].Value; - var webhookToken = match.Groups[2].Value; - // Fire and forget - _webhookData = await GetWebhookData(webhookId, webhookToken); + await GetWebhookData(url); // Frontier - Support for Custom URLS } - private async Task GetWebhookData(string id, string token) + private async Task GetWebhookData(string url) { - var response = await _httpClient.GetAsync($"https://discord.com/api/v10/webhooks/{id}/{token}"); + var response = await _httpClient.GetAsync(url); 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}"); + $"Webhook returned bad status code when trying to get webhook data (perhaps the webhook URL is invalid?): {response.StatusCode}\nResponse: {content}"); return null; } @@ -480,6 +478,7 @@ private async void ProcessQueue(NetUserId userId, Queue mess var payload = GeneratePayload(existingEmbed.Description, existingEmbed.Username, + userId.UserId, // Frontier, this is used to identify the players in the webhook existingEmbed.CharacterName); // If there is no existing embed, create a new one @@ -546,7 +545,7 @@ private async void ProcessQueue(NetUserId userId, Queue mess $"**[Go to ahelp](https://discord.com/channels/{guildId}/{channelId}/{existingEmbed.Id})**"); } - payload = GeneratePayload(message.ToString(), existingEmbed.Username, existingEmbed.CharacterName); + payload = GeneratePayload(message.ToString(), existingEmbed.Username, userId, existingEmbed.CharacterName); var request = await _httpClient.PostAsync($"{_onCallUrl}?wait=true", new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); @@ -566,7 +565,7 @@ private async void ProcessQueue(NetUserId userId, Queue mess _processingChannels.Remove(userId); } - private WebhookPayload GeneratePayload(string messages, string username, string? characterName = null) + private WebhookPayload GeneratePayload(string messages, string username, Guid userId, string? characterName = null) // Frontier: added Guid { // Add character name if (characterName != null) @@ -592,6 +591,7 @@ private WebhookPayload GeneratePayload(string messages, string username, string? return new WebhookPayload { Username = username, + UserID = userId, // Frontier, this is used to identify the players in the webhook AvatarUrl = string.IsNullOrWhiteSpace(_avatarUrl) ? null : _avatarUrl, Embeds = new List { @@ -629,10 +629,20 @@ public override void Update(float frameTime) } } + // Frontier: webhook text messages + public void OnWebhookBwoinkTextMessage(BwoinkTextMessage message, ServerApi.BwoinkActionBody body) + { + // Note for forks: + AdminData webhookAdminData = new(); + + // TODO: fix args + OnBwoinkInternal(message, SystemUserId, webhookAdminData, body.Username, null, body.UserOnly, body.WebhookUpdate, true); + } + protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySessionEventArgs eventArgs) { base.OnBwoinkTextMessage(message, eventArgs); - _activeConversations[message.UserId] = DateTime.Now; + var senderSession = eventArgs.SenderSession; // TODO: Sanitize text? @@ -650,6 +660,23 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes if (_rateLimit.CountAction(eventArgs.SenderSession, RateLimitKey) != RateLimitStatus.Allowed) return; + OnBwoinkInternal(message, eventArgs.SenderSession.UserId, senderAdmin, eventArgs.SenderSession.Name, eventArgs.SenderSession.Channel, false, true, false); + } + + /// + /// Sends a bwoink. Common to both internal messages (sent via the ahelp or admin interface) and webhook messages (sent through the webhook, e.g. via Discord) + /// + /// The message being sent. + /// The network GUID of the person sending the message. + /// The admin privileges of the person sending the message. + /// The name of the person sending the message. + /// The channel to send a message to, e.g. in case of failure to send + /// If true, message should be sent off through the webhook if possible + /// Message originated from a webhook (e.g. Discord) + private void OnBwoinkInternal(BwoinkTextMessage message, NetUserId senderId, AdminData? senderAdmin, string senderName, INetChannel? senderChannel, bool userOnly, bool sendWebhook, bool fromWebhook) + { + _activeConversations[message.UserId] = DateTime.Now; + var escapedText = FormattedMessage.EscapeText(message.Text); string bwoinkText; @@ -665,31 +692,37 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes senderAdmin.Flags == AdminFlags.Adminhelp) // Mentor. Not full admin. That's why it's colored differently. { - bwoinkText = $"[color=purple]{adminPrefix}{senderSession.Name}[/color]"; + bwoinkText = $"[color=purple]{adminPrefix}{senderName}[/color]"; } - else if (senderAdmin is not null && senderAdmin.HasFlag(AdminFlags.Adminhelp)) + else if (fromWebhook || senderAdmin is not null && senderAdmin.HasFlag(AdminFlags.Adminhelp)) // Frontier: anything sent via webhooks are from an admin. { - bwoinkText = $"[color=red]{adminPrefix}{senderSession.Name}[/color]"; + bwoinkText = $"[color=red]{adminPrefix}{senderName}[/color]"; } else { - bwoinkText = $"{senderSession.Name}"; + bwoinkText = $"{senderName}"; } + if (fromWebhook) + bwoinkText = $"(DISCORD) {bwoinkText}"; + bwoinkText = $"{(message.PlaySound ? "" : "(S) ")}{bwoinkText}: {escapedText}"; // If it's not an admin / admin chooses to keep the sound then play it. - var playSound = !senderAHelpAdmin || message.PlaySound; - var msg = new BwoinkTextMessage(message.UserId, senderSession.UserId, bwoinkText, playSound: playSound); + var playSound = senderAdmin == null || message.PlaySound; + var msg = new BwoinkTextMessage(message.UserId, senderId, bwoinkText, playSound: playSound); LogBwoink(msg); var admins = GetTargetAdmins(); // Notify all admins - foreach (var channel in admins) + if (!userOnly) { - RaiseNetworkEvent(msg, channel); + foreach (var channel in admins) + { + RaiseNetworkEvent(msg, channel); + } } string adminPrefixWebhook = ""; @@ -721,13 +754,16 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes } else { - overrideMsgText = $"{senderSession.Name}"; // Not an admin, name is not overridden. + overrideMsgText = $"{senderName}"; // Not an admin, name is not overridden. } + if (fromWebhook) + overrideMsgText = $"(DC) {overrideMsgText}"; + overrideMsgText = $"{(message.PlaySound ? "" : "(S) ")}{overrideMsgText}: {escapedText}"; RaiseNetworkEvent(new BwoinkTextMessage(message.UserId, - senderSession.UserId, + senderId, overrideMsgText, playSound: playSound), session.Channel); @@ -738,13 +774,13 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes } var sendsWebhook = _webhookUrl != string.Empty; - if (sendsWebhook) + if (sendsWebhook && sendWebhook) { if (!_messageQueues.ContainsKey(msg.UserId)) _messageQueues[msg.UserId] = new Queue(); var str = message.Text; - var unameLength = senderSession.Name.Length; + var unameLength = senderName.Length; if (unameLength + str.Length + _maxAdditionalChars > DescriptionMax) { @@ -753,12 +789,13 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes var nonAfkAdmins = GetNonAfkAdmins(); var messageParams = new AHelpMessageParams( - senderSession.Name, + senderName, str, - !personalChannel, + senderId != message.UserId, _gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"), _gameTicker.RunLevel, playedSound: playSound, + isDiscord: fromWebhook, noReceivers: nonAfkAdmins.Count == 0 ); _messageQueues[msg.UserId].Enqueue(GenerateAHelpMessage(messageParams)); @@ -768,10 +805,14 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes return; // No admin online, let the player know - var systemText = Loc.GetString("bwoink-system-starmute-message-no-other-users"); - var starMuteMsg = new BwoinkTextMessage(message.UserId, SystemUserId, systemText); - RaiseNetworkEvent(starMuteMsg, senderSession.Channel); + if (senderChannel != null) + { + var systemText = Loc.GetString("bwoink-system-starmute-message-no-other-users"); + var starMuteMsg = new BwoinkTextMessage(message.UserId, SystemUserId, systemText); + RaiseNetworkEvent(starMuteMsg, senderChannel); + } } + // End Frontier: private IList GetNonAfkAdmins() { @@ -807,6 +848,10 @@ private static DiscordRelayedData GenerateAHelpMessage(AHelpMessageParams parame stringbuilder.Append($" **{parameters.RoundTime}**"); if (!parameters.PlayedSound) stringbuilder.Append(" **(S)**"); + + if (parameters.IsDiscord) // Frontier - Discord Indicator + stringbuilder.Append(" **(DC)**"); + if (parameters.Icon == null) stringbuilder.Append($" **{parameters.Username}:** "); else @@ -870,6 +915,7 @@ public sealed class AHelpMessageParams public GameRunLevel RoundState { get; set; } public bool PlayedSound { get; set; } public bool NoReceivers { get; set; } + public bool IsDiscord { get; set; } // Frontier public string? Icon { get; set; } public AHelpMessageParams( @@ -879,6 +925,7 @@ public AHelpMessageParams( string roundTime, GameRunLevel roundState, bool playedSound, + bool isDiscord = false, // Frontier bool noReceivers = false, string? icon = null) { @@ -887,6 +934,7 @@ public AHelpMessageParams( IsAdmin = isAdmin; RoundTime = roundTime; RoundState = roundState; + IsDiscord = isDiscord; // Frontier PlayedSound = playedSound; NoReceivers = noReceivers; Icon = icon; diff --git a/Content.Server/Discord/WebhookPayload.cs b/Content.Server/Discord/WebhookPayload.cs index fdf5f48444..8d587e0bd1 100644 --- a/Content.Server/Discord/WebhookPayload.cs +++ b/Content.Server/Discord/WebhookPayload.cs @@ -5,6 +5,8 @@ namespace Content.Server.Discord; // https://discord.com/developers/docs/resources/channel#message-object-message-structure public struct WebhookPayload { + [JsonPropertyName("UserID")] // Frontier, this is used to identify the players in the webhook + public Guid? UserID { get; set; } /// /// The message to send in the webhook. Maximum of 2000 characters. ///