diff --git a/Content.Server/GameTicking/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs index fff054bcd73..8485116e3db 100644 --- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs +++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs @@ -237,6 +237,7 @@ public void StartRound(bool force = false) UpdateLateJoinStatus(); AnnounceRound(); UpdateInfoText(); + RaiseLocalEvent(new RoundStartedEvent(RoundId)); #if EXCEPTION_TOLERANCE } @@ -359,6 +360,7 @@ public void ShowRoundEndScoreboard(string text = "") RaiseNetworkEvent(new RoundEndMessageEvent(gamemodeTitle, roundEndText, roundDuration, RoundId, listOfPlayerInfoFinal.Length, listOfPlayerInfoFinal, LobbySong, new SoundCollectionSpecifier("RoundEnd").GetSound())); + RaiseLocalEvent(new RoundEndedEvent(RoundId, roundDuration)); } public void RestartRound() diff --git a/Content.Server/Nyanotrasen/GameTicking/RoundEndedEvent.cs b/Content.Server/Nyanotrasen/GameTicking/RoundEndedEvent.cs new file mode 100644 index 00000000000..360d4dd22c9 --- /dev/null +++ b/Content.Server/Nyanotrasen/GameTicking/RoundEndedEvent.cs @@ -0,0 +1,13 @@ +namespace Content.Shared.GameTicking; + +public sealed class RoundEndedEvent : EntityEventArgs +{ + public int RoundId { get; } + public TimeSpan RoundDuration { get; } + + public RoundEndedEvent(int roundId, TimeSpan roundDuration) + { + RoundId = roundId; + RoundDuration = roundDuration; + } +} diff --git a/Content.Server/Nyanotrasen/RoundNotifications/RoundNotificationsSystem.cs b/Content.Server/Nyanotrasen/RoundNotifications/RoundNotificationsSystem.cs new file mode 100644 index 00000000000..daaf75a4372 --- /dev/null +++ b/Content.Server/Nyanotrasen/RoundNotifications/RoundNotificationsSystem.cs @@ -0,0 +1,179 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Content.Shared.CCVar; +using Content.Server.Maps; +using Content.Shared.GameTicking; +using Robust.Shared; +using Robust.Shared.Configuration; + +namespace Content.Server.Nyanotrasen.RoundNotifications; + +/// +/// Listen game events and send notifications to Discord +/// +public sealed class RoundNotificationsSystem : EntitySystem +{ + [Dependency] private readonly IConfigurationManager _config = default!; + [Dependency] private readonly IGameMapManager _gameMapManager = default!; + + private ISawmill _sawmill = default!; + private readonly HttpClient _httpClient = new(); + + private string _webhookUrl = String.Empty; + private string _roleId = String.Empty; + private bool _roundStartOnly; + private string _serverName = string.Empty; + + /// + public override void Initialize() + { + SubscribeLocalEvent(OnRoundRestart); + SubscribeLocalEvent(OnRoundStarted); + SubscribeLocalEvent(OnRoundEnded); + + _config.OnValueChanged(CCVars.DiscordRoundWebhook, value => _webhookUrl = value, true); + _config.OnValueChanged(CCVars.DiscordRoundRoleId, value => _roleId = value, true); + _config.OnValueChanged(CCVars.DiscordRoundStartOnly, value => _roundStartOnly = value, true); + _config.OnValueChanged(CVars.GameHostName, OnServerNameChanged, true); + + _sawmill = IoCManager.Resolve().GetSawmill("notifications"); + } + + private void OnServerNameChanged(string obj) + { + _serverName = obj; + } + + private void OnRoundRestart(RoundRestartCleanupEvent e) + { + if (String.IsNullOrEmpty(_webhookUrl)) + return; + + var text = Loc.GetString("discord-round-new"); + + SendDiscordMessage(text, true, 0x91B2C7); + } + + private void OnRoundStarted(RoundStartedEvent e) + { + if (String.IsNullOrEmpty(_webhookUrl)) + return; + + var map = _gameMapManager.GetSelectedMap(); + var mapName = map?.MapName ?? Loc.GetString("discord-round-unknown-map"); + var text = Loc.GetString("discord-round-start", + ("id", e.RoundId), + ("map", mapName)); + + SendDiscordMessage(text, false); + } + + private void OnRoundEnded(RoundEndedEvent e) + { + if (String.IsNullOrEmpty(_webhookUrl) || _roundStartOnly) + return; + + var text = Loc.GetString("discord-round-end", + ("id", e.RoundId), + ("hours", Math.Truncate(e.RoundDuration.TotalHours)), + ("minutes", e.RoundDuration.Minutes), + ("seconds", e.RoundDuration.Seconds)); + + SendDiscordMessage(text, false, 0xB22B27); + } + + private async void SendDiscordMessage(string text, bool ping = false, int color = 0x41F097) + { + + // Limit server name to 1500 characters, in case someone tries to be a little funny + var serverName = _serverName[..Math.Min(_serverName.Length, 1500)]; + var message = ""; + if (!String.IsNullOrEmpty(_roleId) && ping) + message = $"<@&{_roleId}>"; + + // Build the embed + var payload = new WebhookPayload + { + Message = message, + Embeds = new List + { + new() + { + Title = Loc.GetString("discord-round-title"), + Description = text, + Color = color, + Footer = new EmbedFooter + { + Text = $"{serverName}" + }, + }, + }, + }; + if (!String.IsNullOrEmpty(_roleId) && ping) + payload.AllowedMentions = new Dictionary {{ "roles", new []{ _roleId } }}; + + 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: {request.StatusCode}\nResponse: {content}"); + return; + } + } + +// https://discord.com/developers/docs/resources/channel#message-object-message-structure + private struct WebhookPayload + { + [JsonPropertyName("username")] public string? Username { get; set; } = null; + + [JsonPropertyName("avatar_url")] public string? AvatarUrl { get; set; } = null; + + [JsonPropertyName("content")] public string Message { 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("title")] public string Title { get; set; } = ""; + + [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.Server/_NF/GameRule/NfAdventureRuleSystem.cs b/Content.Server/_NF/GameRule/NfAdventureRuleSystem.cs index 8493e98d5ca..0dadb934fa2 100644 --- a/Content.Server/_NF/GameRule/NfAdventureRuleSystem.cs +++ b/Content.Server/_NF/GameRule/NfAdventureRuleSystem.cs @@ -222,14 +222,26 @@ private void OnStartup(RoundStartingEvent ev) } } - private async Task ReportRound(String message) + private async Task ReportRound(String message, int color = 0x77DDE7) { Logger.InfoS("discord", message); - String _webhookUrl = _configurationManager.GetCVar(CCVars.DiscordEndRoundWebhook); + String _webhookUrl = _configurationManager.GetCVar(CCVars.DiscordLeaderboardWebhook); if (_webhookUrl == string.Empty) return; - var payload = new WebhookPayload{ Content = message }; + var payload = new WebhookPayload + { + Embeds = new List + { + new() + { + Title = Loc.GetString("adventure-list-start"), + Description = message, + Color = color, + }, + }, + }; + var ser_payload = JsonSerializer.Serialize(payload); var content = new StringContent(ser_payload, Encoding.UTF8, "application/json"); var request = await _httpClient.PostAsync($"{_webhookUrl}?wait=true", content); @@ -240,9 +252,54 @@ private async Task ReportRound(String message) } } +// https://discord.com/developers/docs/resources/channel#message-object-message-structure private struct WebhookPayload { - [JsonPropertyName("content")] - public String Content { get; set; } + [JsonPropertyName("username")] public string? Username { get; set; } = null; + + [JsonPropertyName("avatar_url")] public string? AvatarUrl { get; set; } = null; + + [JsonPropertyName("content")] public string Message { 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("title")] public string Title { get; set; } = ""; + + [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 95d263df35c..d4a578ce8c6 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -322,12 +322,6 @@ public static readonly CVarDef public static readonly CVarDef DiscordAHelpWebhook = CVarDef.Create("discord.ahelp_webhook", string.Empty, CVar.SERVERONLY); - /// - /// URL of the Discord webhook which will relay all round end messages. - /// - public static readonly CVarDef DiscordEndRoundWebhook = - CVarDef.Create("discord.end_round_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. @@ -341,6 +335,30 @@ public static readonly CVarDef public static readonly CVarDef DiscordAHelpAvatar = CVarDef.Create("discord.ahelp_avatar", string.Empty, CVar.SERVERONLY); + /// + /// URL of the Discord webhook which will send round status notifications. + /// + public static readonly CVarDef DiscordRoundWebhook = + CVarDef.Create("discord.round_webhook", string.Empty, CVar.SERVERONLY); + + /// + /// Discord ID of role which will be pinged on new round start message. + /// + public static readonly CVarDef DiscordRoundRoleId = + CVarDef.Create("discord.round_roleid", string.Empty, CVar.SERVERONLY); + + /// + /// Send notifications only about a new round begins. + /// + public static readonly CVarDef DiscordRoundStartOnly = + CVarDef.Create("discord.round_start_only", false, CVar.SERVERONLY); + + /// + /// URL of the Discord webhook which will relay all round end messages. + /// + public static readonly CVarDef DiscordLeaderboardWebhook = + CVarDef.Create("discord.leaderboard_webhook", string.Empty, CVar.SERVERONLY); + /* * Suspicion */ diff --git a/Content.Shared/GameTicking/RoundRestartedEvent.cs b/Content.Shared/GameTicking/RoundRestartedEvent.cs new file mode 100644 index 00000000000..be095f86d5f --- /dev/null +++ b/Content.Shared/GameTicking/RoundRestartedEvent.cs @@ -0,0 +1,15 @@ +using Robust.Shared.Serialization; + +namespace Content.Shared.GameTicking +{ + [Serializable, NetSerializable] + public sealed class RoundStartedEvent : EntityEventArgs + { + public int RoundId { get; } + + public RoundStartedEvent(int roundId) + { + RoundId = roundId; + } + } +} \ No newline at end of file diff --git a/Resources/Locale/en-US/round-notifications/notifications.ftl b/Resources/Locale/en-US/round-notifications/notifications.ftl new file mode 100644 index 00000000000..c497eec76ef --- /dev/null +++ b/Resources/Locale/en-US/round-notifications/notifications.ftl @@ -0,0 +1,4 @@ +discord-round-new = A new round is starting! +discord-round-start = Round #{ $id } on map "{ $map }" has started. +discord-round-end = Round #{ $id } has ended. It lasted for {$hours} hours, {$minutes} minutes, and {$seconds} seconds. +discord-round-title = Round Notification