using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; using System.Threading.Tasks; using Content.Server.Administration.Managers; using Content.Server.Discord; using Content.Server.GameTicking; using Content.Shared.Administration; using Content.Shared.CCVar; using Content.Shared.Mind; using JetBrains.Annotations; using Robust.Server.Player; using Robust.Shared; using Robust.Shared.Configuration; using Robust.Shared.Enums; using Robust.Shared.Network; using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Content.Server.Administration.Systems { [UsedImplicitly] public sealed class BwoinkSystem : SharedBwoinkSystem { [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IAdminManager _adminManager = default!; [Dependency] private readonly IConfigurationManager _config = default!; [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IPlayerLocator _playerLocator = default!; [Dependency] private readonly GameTicker _gameTicker = default!; [Dependency] private readonly SharedMindSystem _minds = default!; private ISawmill _sawmill = default!; private readonly HttpClient _httpClient = new(); private string _webhookUrl = string.Empty; private WebhookData? _webhookData; private string _footerIconUrl = string.Empty; private string _avatarUrl = string.Empty; private string _serverName = string.Empty; private readonly Dictionary _relayMessages = new(); private Dictionary _oldMessageIds = new(); private readonly Dictionary> _messageQueues = new(); private readonly HashSet _processingChannels = new(); private readonly Dictionary _typingUpdateTimestamps = new(); // 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; // Maximum length a message can be before it is cut off // Should be shorter than DescriptionMax private const ushort MessageLengthCap = 3000; // Text to be used to cut off messages that are too long. Should be shorter than MessageLengthCap private const string TooLongText = "... **(too long)**"; 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).Length; _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; SubscribeLocalEvent(OnGameRunLevelChanged); SubscribeNetworkEvent(OnClientTypingUpdated); } private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) { if (e.NewStatus != SessionStatus.InGame) return; RaiseNetworkEvent(new BwoinkDiscordRelayUpdated(!string.IsNullOrWhiteSpace(_webhookUrl)), e.Session); } private void OnGameRunLevelChanged(GameRunLevelChangedEvent args) { // Don't make a new embed if we // 1. were in the lobby just now, and // 2. are not entering the lobby or directly into a new round. if (args.Old is GameRunLevel.PreRoundLobby || args.New is not (GameRunLevel.PreRoundLobby or GameRunLevel.InRound)) { return; } // Store the Discord message IDs of the previous round _oldMessageIds = new Dictionary(); foreach (var message in _relayMessages) { var id = message.Value.id; if (id == null) return; _oldMessageIds[message.Key] = id; } _relayMessages.Clear(); } private void OnClientTypingUpdated(BwoinkClientTypingUpdated msg, EntitySessionEventArgs args) { if (_typingUpdateTimestamps.TryGetValue(args.SenderSession.UserId, out var tuple) && tuple.Typing == msg.Typing && tuple.Timestamp + TimeSpan.FromSeconds(1) > _timing.RealTime) { return; } _typingUpdateTimestamps[args.SenderSession.UserId] = (_timing.RealTime, msg.Typing); // Non-admins can only ever type on their own ahelp, guard against fake messages var isAdmin = _adminManager.GetAdminData((IPlayerSession) args.SenderSession)?.HasFlag(AdminFlags.Adminhelp) ?? false; var channel = isAdmin ? msg.Channel : args.SenderSession.UserId; var update = new BwoinkPlayerTypingUpdated(channel, args.SenderSession.Name, msg.Typing); foreach (var admin in GetTargetAdmins()) { if (admin.UserId == args.SenderSession.UserId) continue; RaiseNetworkEvent(update, admin); } } private void OnServerNameChanged(string obj) { _serverName = obj; } public override void Shutdown() { base.Shutdown(); _config.UnsubValueChanged(CCVars.DiscordAHelpWebhook, OnWebhookChanged); _config.UnsubValueChanged(CCVars.DiscordAHelpFooterIcon, OnFooterIconChanged); _config.UnsubValueChanged(CVars.GameHostName, OnServerNameChanged); } private async void OnWebhookChanged(string url) { _webhookUrl = url; RaiseNetworkEvent(new BwoinkDiscordRelayUpdated(!string.IsNullOrWhiteSpace(url))); if (url == string.Empty) return; // Basic sanity check and capturing webhook ID and token var match = Regex.Match(url, @"^https://discord\.com/api/webhooks/(\d+)/((?!.*/).*)$"); if (!match.Success) { // TODO: Ideally, CVar validation during setting should be better integrated Log.Warning("Webhook URL does not appear to be valid. Using anyways..."); return; } if (match.Groups.Count <= 2) { Log.Error("Could not get webhook ID or token."); return; } var webhookId = match.Groups[1].Value; var webhookToken = match.Groups[2].Value; // Fire and forget await SetWebhookData(webhookId, webhookToken); } private async Task SetWebhookData(string id, string token) { var response = await _httpClient.GetAsync($"https://discord.com/api/v10/webhooks/{id}/{token}"); 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}"); return; } _webhookData = JsonSerializer.Deserialize(content); } private void OnFooterIconChanged(string url) { _footerIconUrl = url; } private void OnAvatarChanged(string url) { _avatarUrl = url; } 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; // If there is no existing embed, or it is getting too long, we create a new embed if (!exists || tooLong) { var lookup = await _playerLocator.LookupIdAsync(userId); if (lookup == null) { _sawmill.Log(LogLevel.Error, $"Unable to find player for NetUserId {userId} when sending discord webhook."); _relayMessages.Remove(userId); return; } var linkToPrevious = string.Empty; // 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) { 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"; } } var characterName = _minds.GetCharacterName(userId); existingEmbed = (null, lookup.Username, linkToPrevious, characterName, _gameTicker.RunLevel); } // Previous message was in another RunLevel, so show that in the embed if (existingEmbed.lastRunLevel != _gameTicker.RunLevel) { 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", GameRunLevel.PostRound => "\n\n:stop_button: _**Post-round started**_\n", _ => throw new ArgumentOutOfRangeException(nameof(_gameTicker.RunLevel), $"{_gameTicker.RunLevel} was not matched."), }; existingEmbed.lastRunLevel = _gameTicker.RunLevel; } // 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; existingEmbed.description += $"\n{message}"; } 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) { var request = await _httpClient.PostAsync($"{_webhookUrl}?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 message (perhaps the message is too long?): {request.StatusCode}\nResponse: {content}"); _relayMessages.Remove(userId); return; } 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}"); _relayMessages.Remove(userId); return; } existingEmbed.id = id.ToString(); } else { var request = await _httpClient.PatchAsync($"{_webhookUrl}/messages/{existingEmbed.id}", new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); 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}"); _relayMessages.Remove(userId); return; } } _relayMessages[userId] = existingEmbed; _processingChannels.Remove(userId); } private WebhookPayload GeneratePayload(string messages, string username, string? characterName = null) { // Add character name if (characterName != null) username += $" ({characterName})"; // If no admins are online, set embed color to red. Otherwise green var color = GetTargetAdmins().Count > 0 ? 0x41F097 : 0xFF0000; // Limit server name to 1500 characters, in case someone tries to be a little funny var serverName = _serverName[..Math.Min(_serverName.Length, 1500)]; var round = _gameTicker.RunLevel switch { GameRunLevel.PreRoundLobby => _gameTicker.RoundId == 0 ? "pre-round lobby after server restart" // first round after server restart has ID == 0 : $"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."), }; return new WebhookPayload { Username = username, AvatarUrl = string.IsNullOrWhiteSpace(_avatarUrl) ? null : _avatarUrl, Embeds = new List { new() { Description = messages, Color = color, Footer = new WebhookEmbedFooter { Text = $"{serverName} ({round})", IconUrl = string.IsNullOrWhiteSpace(_footerIconUrl) ? null : _footerIconUrl }, }, }, }; } public override void Update(float frameTime) { base.Update(frameTime); foreach (var userId in _messageQueues.Keys.ToArray()) { if (_processingChannels.Contains(userId)) continue; var queue = _messageQueues[userId]; _messageQueues.Remove(userId); if (queue.Count == 0) continue; _processingChannels.Add(userId); ProcessQueue(userId, queue); } } protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySessionEventArgs eventArgs) { base.OnBwoinkTextMessage(message, eventArgs); var senderSession = (IPlayerSession) eventArgs.SenderSession; // TODO: Sanitize text? // Confirm that this person is actually allowed to send a message here. var personalChannel = senderSession.UserId == message.UserId; var senderAdmin = _adminManager.GetAdminData(senderSession); var senderAHelpAdmin = senderAdmin?.HasFlag(AdminFlags.Adminhelp) ?? false; var authorized = personalChannel || senderAHelpAdmin; if (!authorized) { // Unauthorized bwoink (log?) return; } var escapedText = FormattedMessage.EscapeText(message.Text); var bwoinkText = senderAdmin switch { var x when x is not null && x.Flags == AdminFlags.Adminhelp => $"[color=purple]{senderSession.Name}[/color]: {escapedText}", var x when x is not null && x.HasFlag(AdminFlags.Adminhelp) => $"[color=red]{senderSession.Name}[/color]: {escapedText}", _ => $"{senderSession.Name}: {escapedText}", }; var msg = new BwoinkTextMessage(message.UserId, senderSession.UserId, bwoinkText); LogBwoink(msg); var admins = GetTargetAdmins(); // Notify all admins foreach (var channel in admins) { RaiseNetworkEvent(msg, channel); } // Notify player if (_playerManager.TryGetSessionById(message.UserId, out var session)) { if (!admins.Contains(session.ConnectedClient)) RaiseNetworkEvent(msg, session.ConnectedClient); } var sendsWebhook = _webhookUrl != string.Empty; if (sendsWebhook) { if (!_messageQueues.ContainsKey(msg.UserId)) _messageQueues[msg.UserId] = new Queue(); var str = message.Text; var unameLength = senderSession.Name.Length; if (unameLength + str.Length + _maxAdditionalChars > DescriptionMax) { str = str[..(DescriptionMax - _maxAdditionalChars - unameLength)]; } _messageQueues[msg.UserId].Enqueue(GenerateAHelpMessage(senderSession.Name, str, !personalChannel, admins.Count == 0)); } if (admins.Count != 0 || sendsWebhook) 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.ConnectedClient); } // 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, bool noReceivers = false) { var stringbuilder = new StringBuilder(); if (admin) stringbuilder.Append(":outbox_tray:"); else if (noReceivers) stringbuilder.Append(":sos:"); else stringbuilder.Append(":inbox_tray:"); stringbuilder.Append($" **{username}:** "); stringbuilder.Append(message); return stringbuilder.ToString(); } } }