diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index 107f299dbf..72e78435e6 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -3,10 +3,10 @@ using System.Net.Http; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.Json.Serialization; using System.Text.RegularExpressions; using System.Threading.Tasks; using Content.Server.Administration.Managers; +using Content.Server.Discord; using Content.Server.GameTicking; using Content.Server.Players; using Content.Shared.Administration; @@ -337,13 +337,13 @@ namespace Content.Server.Administration.Systems { Username = username, AvatarUrl = string.IsNullOrWhiteSpace(_avatarUrl) ? null : _avatarUrl, - Embeds = new List + Embeds = new List { new() { Description = messages, Color = color, - Footer = new EmbedFooter + Footer = new WebhookEmbedFooter { Text = $"{serverName} ({round})", IconUrl = string.IsNullOrWhiteSpace(_footerIconUrl) ? null : _footerIconUrl @@ -469,75 +469,6 @@ namespace Content.Server.Administration.Systems 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("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() }, - }; - - 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() - { - } - } - - // https://discord.com/developers/docs/resources/webhook#webhook-object-webhook-structure - private struct WebhookData - { - [JsonPropertyName("guild_id")] - public string? GuildId { get; set; } = null; - - [JsonPropertyName("channel_id")] - public string? ChannelId { get; set; } = null; - - public WebhookData() - { - } - } } } diff --git a/Content.Server/Discord/DiscordWebhook.cs b/Content.Server/Discord/DiscordWebhook.cs new file mode 100644 index 0000000000..d8a9318444 --- /dev/null +++ b/Content.Server/Discord/DiscordWebhook.cs @@ -0,0 +1,101 @@ +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; + +namespace Content.Server.Discord; + +public sealed class DiscordWebhook : IPostInjectInit +{ + [Dependency] private readonly ILogManager _log = default!; + + private const string BaseUrl = "https://discord.com/api/v10/webhooks"; + private readonly HttpClient _http = new(); + private ISawmill _sawmill = default!; + + private string GetUrl(WebhookIdentifier identifier) + { + return $"{BaseUrl}/{identifier.Id}/{identifier.Token}"; + } + + /// + /// Gets the webhook data from the given webhook url. + /// + /// The url to get the data from. + /// The webhook data returned from the url. + public async Task GetWebhook(string url) + { + try + { + return await _http.GetFromJsonAsync(url); + } + catch + { + _sawmill.Error($"Error getting discord webhook data. Stack trace:\n{Environment.StackTrace}"); + return null; + } + } + + /// + /// Gets the webhook data from the given webhook url. + /// + /// The url to get the data from. + /// The delegate to invoke with the obtained data, if any. + public async void GetWebhook(string url, Action onComplete) + { + if (await GetWebhook(url) is { } data) + onComplete(data); + } + + /// + /// Tries to get the webhook data from the given webhook url if it is not null or whitespace. + /// + /// The url to get the data from. + /// The delegate to invoke with the obtained data, if any. + public async void TryGetWebhook(string url, Action onComplete) + { + if (await GetWebhook(url) is { } data) + onComplete(data); + } + + /// + /// Creates a new webhook message with the given identifier and payload. + /// + /// The identifier for the webhook url. + /// The payload to create the message from. + /// The response from Discord's API. + public async Task CreateMessage(WebhookIdentifier identifier, WebhookPayload payload) + { + var url = $"{GetUrl(identifier)}?wait=true"; + return await _http.PostAsJsonAsync(url, payload); + } + + /// + /// Deletes a webhook message with the given identifier and message id. + /// + /// The identifier for the webhook url. + /// The message id to delete. + /// The response from Discord's API. + public async Task DeleteMessage(WebhookIdentifier identifier, ulong messageId) + { + var url = $"{GetUrl(identifier)}/messages/{messageId}"; + return await _http.DeleteAsync(url); + } + + /// + /// Creates a new webhook message with the given identifier, message id and payload. + /// + /// The identifier for the webhook url. + /// The message id to edit. + /// The payload used to edit the message. + /// The response from Discord's API. + public async Task EditMessage(WebhookIdentifier identifier, ulong messageId, WebhookPayload payload) + { + var url = $"{GetUrl(identifier)}/messages/{messageId}"; + return await _http.PatchAsJsonAsync(url, payload); + } + + void IPostInjectInit.PostInject() + { + _sawmill = _log.GetSawmill("DISCORD"); + } +} diff --git a/Content.Server/Discord/WebhookData.cs b/Content.Server/Discord/WebhookData.cs new file mode 100644 index 0000000000..5e23fa132c --- /dev/null +++ b/Content.Server/Discord/WebhookData.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace Content.Server.Discord; + +// https://discord.com/developers/docs/resources/webhook#webhook-object-webhook-structure +public struct WebhookData +{ + [JsonPropertyName("id")] + public ulong Id { get; set; } + + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("guild_id")] + public ulong? GuildId { get; set; } + + [JsonPropertyName("channel_id")] + public ulong? ChannelId { get; set; } + + [JsonPropertyName("user")] + public WebhookUser? User { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("avatar")] + public string? Avatar { get; set; } + + [JsonPropertyName("token")] + public string Token { get; set; } + + [JsonPropertyName("application_id")] + public ulong? ApplicationId { get; set; } + + [JsonPropertyName("url")] + public string? Url { get; set; } + + public WebhookIdentifier ToIdentifier() + { + return new WebhookIdentifier(Id, Token); + } +} diff --git a/Content.Server/Discord/WebhookEmbed.cs b/Content.Server/Discord/WebhookEmbed.cs new file mode 100644 index 0000000000..8e0db0722c --- /dev/null +++ b/Content.Server/Discord/WebhookEmbed.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Content.Server.Discord; + +// https://discord.com/developers/docs/resources/channel#embed-object-embed-structure +public struct WebhookEmbed +{ + [JsonPropertyName("description")] + public string Description { get; set; } = ""; + + [JsonPropertyName("color")] + public int Color { get; set; } = 0; + + [JsonPropertyName("footer")] + public WebhookEmbedFooter? Footer { get; set; } = null; + + public WebhookEmbed() + { + } +} diff --git a/Content.Server/Discord/WebhookEmbedFooter.cs b/Content.Server/Discord/WebhookEmbedFooter.cs new file mode 100644 index 0000000000..9eda006ed2 --- /dev/null +++ b/Content.Server/Discord/WebhookEmbedFooter.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Content.Server.Discord; + +// https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure +public struct WebhookEmbedFooter +{ + [JsonPropertyName("text")] + public string Text { get; set; } = ""; + + [JsonPropertyName("icon_url")] + public string? IconUrl { get; set; } + + public WebhookEmbedFooter() + { + } +} diff --git a/Content.Server/Discord/WebhookIdentifier.cs b/Content.Server/Discord/WebhookIdentifier.cs new file mode 100644 index 0000000000..9d0fba3d10 --- /dev/null +++ b/Content.Server/Discord/WebhookIdentifier.cs @@ -0,0 +1,3 @@ +namespace Content.Server.Discord; + +public record struct WebhookIdentifier(ulong Id, string Token); diff --git a/Content.Server/Discord/WebhookMentions.cs b/Content.Server/Discord/WebhookMentions.cs new file mode 100644 index 0000000000..da945b363e --- /dev/null +++ b/Content.Server/Discord/WebhookMentions.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Content.Server.Discord; + +public struct WebhookMentions +{ + [JsonPropertyName("parse")] + public HashSet Parse { get; set; } = new(); + + public WebhookMentions() + { + } + + public void AllowRoleMentions() + { + Parse.Add("roles"); + } +} diff --git a/Content.Server/Discord/WebhookPayload.cs b/Content.Server/Discord/WebhookPayload.cs new file mode 100644 index 0000000000..6300f86ad5 --- /dev/null +++ b/Content.Server/Discord/WebhookPayload.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace Content.Server.Discord; + +// https://discord.com/developers/docs/resources/channel#message-object-message-structure +public struct WebhookPayload +{ + /// + /// The message to send in the webhook. Maximum of 2000 characters. + /// + [JsonPropertyName("content")] + public string Content { get; set; } = ""; + + [JsonPropertyName("username")] + public string? Username { get; set; } + + [JsonPropertyName("avatar_url")] + public string? AvatarUrl { get; set; } = ""; + + [JsonPropertyName("embeds")] + public List? Embeds { get; set; } = null; + + [JsonPropertyName("allowed_mentions")] + public WebhookMentions AllowedMentions { get; set; } = new(); + + public WebhookPayload() + { + } +} diff --git a/Content.Server/Discord/WebhookUser.cs b/Content.Server/Discord/WebhookUser.cs new file mode 100644 index 0000000000..3149c4f421 --- /dev/null +++ b/Content.Server/Discord/WebhookUser.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace Content.Server.Discord; + +// https://discord.com/developers/docs/resources/user#user-object +public struct WebhookUser +{ + [JsonPropertyName("id")] + public ulong Id { get; set; } + + [JsonPropertyName("username")] + public string Username { get; set; } + + [JsonPropertyName("discriminator")] + public string Discriminator { get; set; } + + [JsonPropertyName("global_name")] + public string? GlobalName { get; set; } + + [JsonPropertyName("avatar")] + public string? Avatar { get; set; } + + [JsonPropertyName("bot")] + public bool? Bot { get; set; } + + [JsonPropertyName("system")] + public bool? System { get; set; } + + [JsonPropertyName("mfa_enabled")] + public bool? MfaEnabled { get; set; } + + [JsonPropertyName("banner")] + public string? Banner { get; set; } + + [JsonPropertyName("accent_color")] + public int? AccentColor { get; set; } + + [JsonPropertyName("locale")] + public string? Locale { get; set; } + + [JsonPropertyName("verified")] + public bool? Verified { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("flags")] + public int? Flags { get; set; } + + [JsonPropertyName("premium_type")] + public int? PremiumType { get; set; } + + [JsonPropertyName("public_flags")] + public int? PublicFlags { get; set; } + + [JsonPropertyName("avatar_decoration")] + public string? AvatarDecoration { get; set; } +} diff --git a/Content.Server/GameTicking/GameTicker.CVars.cs b/Content.Server/GameTicking/GameTicker.CVars.cs index bc8dfb6783..8bb7f83856 100644 --- a/Content.Server/GameTicking/GameTicker.CVars.cs +++ b/Content.Server/GameTicking/GameTicker.CVars.cs @@ -1,3 +1,4 @@ +using Content.Server.Discord; using Content.Shared.CCVar; using Content.Shared.GameTicking; @@ -26,6 +27,14 @@ namespace Content.Server.GameTicking [ViewVariables] public float MaxStationOffset { get; private set; } = 0f; + [ViewVariables] + public string? ServerName { get; private set; } + + [ViewVariables] + private long? DiscordRoundEndRole { get; set; } + + private WebhookIdentifier? _webhookIdentifier; + #if EXCEPTION_TOLERANCE [ViewVariables] public int RoundStartFailShutdownCount { get; private set; } = 0; @@ -51,6 +60,27 @@ namespace Content.Server.GameTicking _configurationManager.OnValueChanged(CCVars.StationOffset, value => StationOffset = value, true); _configurationManager.OnValueChanged(CCVars.StationRotation, value => StationRotation = value, true); _configurationManager.OnValueChanged(CCVars.MaxStationOffset, value => MaxStationOffset = value, true); + _configurationManager.OnValueChanged(CCVars.AdminLogsServerName, value => + { + // TODO why tf is the server name on admin logs + ServerName = value; + }, true); + _configurationManager.OnValueChanged(CCVars.DiscordRoundRestartWebhook, value => + { + if (!string.IsNullOrWhiteSpace(value)) + { + _discord.GetWebhook(value, data => _webhookIdentifier = data.ToIdentifier()); + } + }, true); + _configurationManager.OnValueChanged(CCVars.DiscordRoundEndRoleWebhook, value => + { + DiscordRoundEndRole = value; + + if (value == 0) + { + DiscordRoundEndRole = null; + } + }, true); #if EXCEPTION_TOLERANCE _configurationManager.OnValueChanged(CCVars.RoundStartFailShutdownCount, value => RoundStartFailShutdownCount = value, true); #endif diff --git a/Content.Server/GameTicking/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs index dbd029be84..a20cefb233 100644 --- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs +++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs @@ -16,6 +16,7 @@ using Robust.Shared.Player; using Robust.Shared.Random; using Robust.Shared.Utility; using System.Linq; +using Content.Server.Discord; using Content.Shared.Database; using Robust.Shared.Asynchronous; using PlayerData = Content.Server.Players.PlayerData; @@ -24,6 +25,7 @@ namespace Content.Server.GameTicking { public sealed partial class GameTicker { + [Dependency] private readonly DiscordWebhook _discord = default!; [Dependency] private readonly ITaskManager _taskManager = default!; private static readonly Counter RoundNumberMetric = Metrics.CreateCounter( @@ -303,6 +305,7 @@ namespace Content.Server.GameTicking LobbySong = _robustRandom.Pick(_lobbyMusicCollection.PickFiles).ToString(); ShowRoundEndScoreboard(text); + SendRoundEndDiscordMessage(); } public void ShowRoundEndScoreboard(string text = "") @@ -385,6 +388,33 @@ namespace Content.Server.GameTicking new SoundCollectionSpecifier("RoundEnd").GetSound())); } + private async void SendRoundEndDiscordMessage() + { + try + { + if (_webhookIdentifier == null) + return; + + var content = "The round has ended."; + if (DiscordRoundEndRole != null) + content += $"\n\n<@&{DiscordRoundEndRole}>, the server will reboot shortly!"; + + var payload = new WebhookPayload + { + Content = content, + Username = ServerName, + }; + + payload.AllowedMentions.AllowRoleMentions(); + + await _discord.CreateMessage(_webhookIdentifier.Value, payload); + } + catch (Exception e) + { + Log.Error($"Error while sending discord round end message:\n{e}"); + } + } + public void RestartRound() { // If this game ticker is a dummy, do nothing! @@ -426,6 +456,31 @@ namespace Content.Server.GameTicking ReqWindowAttentionAll(); } + + SendRoundStartingDiscordMessage(); + } + + private async void SendRoundStartingDiscordMessage() + { + try + { + if (_webhookIdentifier == null) + return; + + var content = "New round starting!"; + + var payload = new WebhookPayload + { + Content = content, + Username = ServerName, + }; + + await _discord.CreateMessage(_webhookIdentifier.Value, payload); + } + catch (Exception e) + { + Log.Error($"Error while sending discord round starting message:\n{e}"); + } } /// diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index 88d43213ba..ade4ecea52 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -6,6 +6,7 @@ using Content.Server.Afk; using Content.Server.Chat.Managers; using Content.Server.Connection; using Content.Server.Database; +using Content.Server.Discord; using Content.Server.EUI; using Content.Server.GhostKick; using Content.Server.Info; @@ -58,6 +59,7 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index ce768b418f..14357e7f26 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -341,6 +341,18 @@ namespace Content.Shared.CCVar public static readonly CVarDef DiscordAHelpAvatar = CVarDef.Create("discord.ahelp_avatar", string.Empty, CVar.SERVERONLY); + /// + /// URL of the Discord webhook which will relay round restart messages. + /// + public static readonly CVarDef DiscordRoundRestartWebhook = + CVarDef.Create("discord.round_restart_webhook", string.Empty, CVar.SERVERONLY); + + /// + /// Role id for the Discord webhook to ping when the round ends. + /// + public static readonly CVarDef DiscordRoundEndRoleWebhook = + CVarDef.Create("discord.round_end_role", 0L, CVar.SERVERONLY); + /* * Suspicion */