diff --git a/Content.Server/Discord/WebhookMessages/VoteWebhooks.cs b/Content.Server/Discord/WebhookMessages/VoteWebhooks.cs new file mode 100644 index 0000000000..74953d10fa --- /dev/null +++ b/Content.Server/Discord/WebhookMessages/VoteWebhooks.cs @@ -0,0 +1,183 @@ +using Content.Server.GameTicking; +using Content.Server.Voting; +using Robust.Server; +using Robust.Shared.Configuration; +using Robust.Shared.Utility; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Content.Server.Discord.WebhookMessages; + +public sealed class VoteWebhooks : IPostInjectInit +{ + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IEntitySystemManager _entSys = default!; + [Dependency] private readonly DiscordWebhook _discord = default!; + [Dependency] private readonly IBaseServer _baseServer = default!; + + private ISawmill _sawmill = default!; + + public WebhookState? CreateWebhookIfConfigured(VoteOptions voteOptions, string? webhookUrl = null, string? customVoteName = null, string? customVoteMessage = null) + { + // All this webhook code is complete garbage. + // I tried to clean it up somewhat, at least to fix the glaring bugs in it. + // Jesus christ man what is with our code review process. + + if (string.IsNullOrEmpty(webhookUrl)) + return null; + + // Set up the webhook payload + var serverName = _baseServer.ServerName; + + var fields = new List(); + + foreach (var voteOption in voteOptions.Options) + { + var newVote = new WebhookEmbedField + { + Name = voteOption.text, + Value = Loc.GetString("custom-vote-webhook-option-pending") + }; + fields.Add(newVote); + } + + var gameTicker = _entSys.GetEntitySystemOrNull(); + _sawmill = Logger.GetSawmill("discord"); + + var runLevel = gameTicker != null ? Loc.GetString($"game-run-level-{gameTicker.RunLevel}") : ""; + var runId = gameTicker != null ? gameTicker.RoundId : 0; + + var voteName = customVoteName ?? Loc.GetString("custom-vote-webhook-name"); + var description = customVoteMessage ?? voteOptions.Title; + + var payload = new WebhookPayload() + { + Username = voteName, + Embeds = new List + { + new() + { + Title = voteOptions.InitiatorText, + Color = 13438992, // #CD1010 + Description = description, + Footer = new WebhookEmbedFooter + { + Text = Loc.GetString( + "custom-vote-webhook-footer", + ("serverName", serverName), + ("roundId", runId), + ("runLevel", runLevel)), + }, + + Fields = fields, + }, + }, + }; + + var state = new WebhookState + { + WebhookUrl = webhookUrl, + Payload = payload, + }; + + CreateWebhookMessage(state, payload); + + return state; + } + + public void UpdateWebhookIfConfigured(WebhookState? state, VoteFinishedEventArgs finished) + { + if (state == null) + return; + + var embed = state.Payload.Embeds![0]; + embed.Color = 2353993; // #23EB49 + + for (var i = 0; i < finished.Votes.Count; i++) + { + var oldName = embed.Fields[i].Name; + var newValue = finished.Votes[i].ToString(); + embed.Fields[i] = new WebhookEmbedField { Name = oldName, Value = newValue, Inline = true }; + } + + state.Payload.Embeds[0] = embed; + + UpdateWebhookMessage(state, state.Payload, state.MessageId); + } + + public void UpdateCancelledWebhookIfConfigured(WebhookState? state, string? customCancelReason = null) + { + if (state == null) + return; + + var embed = state.Payload.Embeds![0]; + embed.Color = 13356304; // #CBCD10 + if (customCancelReason == null) + embed.Description += "\n\n" + Loc.GetString("custom-vote-webhook-cancelled"); + else + embed.Description += "\n\n" + customCancelReason; + + for (var i = 0; i < embed.Fields.Count; i++) + { + var oldName = embed.Fields[i].Name; + embed.Fields[i] = new WebhookEmbedField { Name = oldName, Value = Loc.GetString("custom-vote-webhook-option-cancelled"), Inline = true }; + } + + state.Payload.Embeds[0] = embed; + + UpdateWebhookMessage(state, state.Payload, state.MessageId); + } + + // Sends the payload's message. + public async void CreateWebhookMessage(WebhookState state, WebhookPayload payload) + { + try + { + if (await _discord.GetWebhook(state.WebhookUrl) is not { } identifier) + return; + + state.Identifier = identifier.ToIdentifier(); + _sawmill.Debug(JsonSerializer.Serialize(payload)); + + var request = await _discord.CreateMessage(identifier.ToIdentifier(), payload); + var content = await request.Content.ReadAsStringAsync(); + state.MessageId = ulong.Parse(JsonNode.Parse(content)?["id"]!.GetValue()!); + } + catch (Exception e) + { + _sawmill.Error($"Error while sending vote webhook to Discord: {e}"); + } + } + + // Edits a pre-existing payload message, given an ID + public async void UpdateWebhookMessage(WebhookState state, WebhookPayload payload, ulong id) + { + if (state.MessageId == 0) + { + _sawmill.Warning("Failed to deliver update to custom vote webhook: message ID was zero. This likely indicates a previous connection error sending the original message."); + return; + } + + DebugTools.Assert(state.Identifier != default); + + try + { + await _discord.EditMessage(state.Identifier, id, payload); + } + catch (Exception e) + { + _sawmill.Error($"Error while updating vote webhook on Discord: {e}"); + } + } + + public sealed class WebhookState + { + public required string WebhookUrl; + public required WebhookPayload Payload; + public WebhookIdentifier Identifier; + public ulong MessageId; + } + + void IPostInjectInit.PostInject() { } +} diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index d7f6b85eb6..902e16d531 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -7,6 +7,7 @@ using Content.Server.Chat.Managers; using Content.Server.Connection; using Content.Server.Database; using Content.Server.Discord; +using Content.Server.Discord.WebhookMessages; using Content.Server.EUI; using Content.Server.GhostKick; using Content.Server.Info; @@ -64,6 +65,7 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); diff --git a/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs b/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs index 0f7d238518..736ff48817 100644 --- a/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs +++ b/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs @@ -2,6 +2,7 @@ using System.Linq; using Content.Server.Administration; using Content.Server.Administration.Managers; using Content.Server.Database; +using Content.Server.Discord.WebhookMessages; using Content.Server.GameTicking; using Content.Server.GameTicking.Presets; using Content.Server.Maps; @@ -27,6 +28,7 @@ namespace Content.Server.Voting.Managers [Dependency] private readonly ILogManager _logManager = default!; [Dependency] private readonly IBanManager _bans = default!; [Dependency] private readonly IServerDbManager _dbManager = default!; + [Dependency] private readonly VoteWebhooks _voteWebhooks = default!; private VotingSystem? _votingSystem; private RoleSystem? _roleSystem; @@ -449,10 +451,13 @@ namespace Content.Server.Voting.Managers var vote = CreateVote(options); _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick for {located.Username} ({targetEntityName}) due to {reason} started, initiated by {initiator}."); + // Create Discord webhook + var webhookState = _voteWebhooks.CreateWebhookIfConfigured(options, _cfg.GetCVar(CCVars.DiscordVotekickWebhook), Loc.GetString("votekick-webhook-name"), options.Title + "\n" + Loc.GetString("votekick-webhook-description", ("initiator", initiatorName), ("target", targetSession))); + // Time out the vote now that we know it will happen TimeoutStandardVote(StandardVoteType.Votekick); - vote.OnFinished += (_, _) => + vote.OnFinished += (_, eventArgs) => { var votesYes = vote.VotesPerOption["yes"]; @@ -487,6 +492,7 @@ namespace Content.Server.Voting.Managers { _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick for {located.Username} attempted to pass, but an admin was online. Yes: {votesYes} / No: {votesNo}. Yes: {yesVotersString} / No: {noVotersString}"); AnnounceCancelledVotekickForVoters(targetEntityName); + _voteWebhooks.UpdateCancelledWebhookIfConfigured(webhookState, Loc.GetString("votekick-webhook-cancelled-admin-online")); return; } // Check if the target is an antag and the vote reason is raiding (this is to prevent false positives) @@ -494,6 +500,7 @@ namespace Content.Server.Voting.Managers { _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick for {located.Username} due to {reason} finished, created by {initiator}, but was cancelled due to the target being an antagonist."); AnnounceCancelledVotekickForVoters(targetEntityName); + _voteWebhooks.UpdateCancelledWebhookIfConfigured(webhookState, Loc.GetString("votekick-webhook-cancelled-antag-target")); return; } // Check if the target is an admin/de-admined admin @@ -501,6 +508,7 @@ namespace Content.Server.Voting.Managers { _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick for {located.Username} due to {reason} finished, created by {initiator}, but was cancelled due to the target being a de-admined admin."); AnnounceCancelledVotekickForVoters(targetEntityName); + _voteWebhooks.UpdateCancelledWebhookIfConfigured(webhookState, Loc.GetString("votekick-webhook-cancelled-admin-target")); return; } else @@ -515,6 +523,9 @@ namespace Content.Server.Voting.Managers severity = NoteSeverity.High; } + // Discord webhook, success + _voteWebhooks.UpdateWebhookIfConfigured(webhookState, eventArgs); + uint minutes = (uint)_cfg.GetCVar(CCVars.VotekickBanDuration); _bans.CreateServerBan(targetUid, target, null, null, targetHWid, minutes, severity, reason); @@ -522,6 +533,10 @@ namespace Content.Server.Voting.Managers } else { + + // Discord webhook, failure + _voteWebhooks.UpdateWebhookIfConfigured(webhookState, eventArgs); + _adminLogger.Add(LogType.Vote, LogImpact.Extreme, $"Votekick failed: Yes: {votesYes} / No: {votesNo}. Yes: {yesVotersString} / No: {noVotersString}"); _chatManager.DispatchServerAnnouncement(Loc.GetString("ui-vote-votekick-failure", ("target", targetEntityName), ("reason", reason))); } diff --git a/Content.Server/Voting/VoteCommands.cs b/Content.Server/Voting/VoteCommands.cs index 7dbb41b50f..d4c236f394 100644 --- a/Content.Server/Voting/VoteCommands.cs +++ b/Content.Server/Voting/VoteCommands.cs @@ -1,11 +1,8 @@ using System.Linq; -using System.Text.Json; -using System.Text.Json.Nodes; using Content.Server.Administration; using Content.Server.Administration.Logs; using Content.Server.Chat.Managers; -using Content.Server.Discord; -using Content.Server.GameTicking; +using Content.Server.Discord.WebhookMessages; using Content.Server.Voting.Managers; using Content.Shared.Administration; using Content.Shared.CCVar; @@ -76,11 +73,9 @@ namespace Content.Server.Voting { [Dependency] private readonly IVoteManager _voteManager = default!; [Dependency] private readonly IAdminLogManager _adminLogger = default!; - [Dependency] private readonly IConfigurationManager _cfg = default!; - [Dependency] private readonly DiscordWebhook _discord = default!; - [Dependency] private readonly GameTicker _gameTicker = default!; - [Dependency] private readonly IBaseServer _baseServer = default!; [Dependency] private readonly IChatManager _chatManager = default!; + [Dependency] private readonly VoteWebhooks _voteWebhooks = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; private ISawmill _sawmill = default!; @@ -120,7 +115,7 @@ namespace Content.Server.Voting var vote = _voteManager.CreateVote(options); - var webhookState = CreateWebhookIfConfigured(options); + var webhookState = _voteWebhooks.CreateWebhookIfConfigured(options, _cfg.GetCVar(CCVars.DiscordVoteWebhook)); vote.OnFinished += (_, eventArgs) => { @@ -136,12 +131,12 @@ namespace Content.Server.Voting _chatManager.DispatchServerAnnouncement(Loc.GetString("cmd-customvote-on-finished-win", ("winner", args[(int) eventArgs.Winner]))); } - UpdateWebhookIfConfigured(webhookState, eventArgs); + _voteWebhooks.UpdateWebhookIfConfigured(webhookState, eventArgs); }; vote.OnCancelled += _ => { - UpdateCancelledWebhookIfConfigured(webhookState); + _voteWebhooks.UpdateCancelledWebhookIfConfigured(webhookState); }; } @@ -156,159 +151,6 @@ namespace Content.Server.Voting var n = args.Length - 1; return CompletionResult.FromHint(Loc.GetString("cmd-customvote-arg-option-n", ("n", n))); } - - private WebhookState? CreateWebhookIfConfigured(VoteOptions voteOptions) - { - // All this webhook code is complete garbage. - // I tried to clean it up somewhat, at least to fix the glaring bugs in it. - // Jesus christ man what is with our code review process. - - var webhookUrl = _cfg.GetCVar(CCVars.DiscordVoteWebhook); - if (string.IsNullOrEmpty(webhookUrl)) - return null; - - // Set up the webhook payload - var serverName = _baseServer.ServerName; - - var fields = new List(); - - foreach (var voteOption in voteOptions.Options) - { - var newVote = new WebhookEmbedField - { - Name = voteOption.text, - Value = Loc.GetString("custom-vote-webhook-option-pending") - }; - fields.Add(newVote); - } - - var runLevel = Loc.GetString($"game-run-level-{_gameTicker.RunLevel}"); - - var payload = new WebhookPayload() - { - Username = Loc.GetString("custom-vote-webhook-name"), - Embeds = new List - { - new() - { - Title = voteOptions.InitiatorText, - Color = 13438992, // #CD1010 - Description = voteOptions.Title, - Footer = new WebhookEmbedFooter - { - Text = Loc.GetString( - "custom-vote-webhook-footer", - ("serverName", serverName), - ("roundId", _gameTicker.RoundId), - ("runLevel", runLevel)), - }, - - Fields = fields, - }, - }, - }; - - var state = new WebhookState - { - WebhookUrl = webhookUrl, - Payload = payload, - }; - - CreateWebhookMessage(state, payload); - - return state; - } - - private void UpdateWebhookIfConfigured(WebhookState? state, VoteFinishedEventArgs finished) - { - if (state == null) - return; - - var embed = state.Payload.Embeds![0]; - embed.Color = 2353993; // #23EB49 - - for (var i = 0; i < finished.Votes.Count; i++) - { - var oldName = embed.Fields[i].Name; - var newValue = finished.Votes[i].ToString(); - embed.Fields[i] = new WebhookEmbedField { Name = oldName, Value = newValue, Inline = true}; - } - - state.Payload.Embeds[0] = embed; - - UpdateWebhookMessage(state, state.Payload, state.MessageId); - } - - private void UpdateCancelledWebhookIfConfigured(WebhookState? state) - { - if (state == null) - return; - - var embed = state.Payload.Embeds![0]; - embed.Color = 13356304; // #CBCD10 - embed.Description += "\n\n" + Loc.GetString("custom-vote-webhook-cancelled"); - - for (var i = 0; i < embed.Fields.Count; i++) - { - var oldName = embed.Fields[i].Name; - embed.Fields[i] = new WebhookEmbedField { Name = oldName, Value = Loc.GetString("custom-vote-webhook-option-cancelled"), Inline = true}; - } - - state.Payload.Embeds[0] = embed; - - UpdateWebhookMessage(state, state.Payload, state.MessageId); - } - - // Sends the payload's message. - private async void CreateWebhookMessage(WebhookState state, WebhookPayload payload) - { - try - { - if (await _discord.GetWebhook(state.WebhookUrl) is not { } identifier) - return; - - state.Identifier = identifier.ToIdentifier(); - - _sawmill.Debug(JsonSerializer.Serialize(payload)); - - var request = await _discord.CreateMessage(identifier.ToIdentifier(), payload); - var content = await request.Content.ReadAsStringAsync(); - state.MessageId = ulong.Parse(JsonNode.Parse(content)?["id"]!.GetValue()!); - } - catch (Exception e) - { - _sawmill.Error($"Error while sending vote webhook to Discord: {e}"); - } - } - - // Edits a pre-existing payload message, given an ID - private async void UpdateWebhookMessage(WebhookState state, WebhookPayload payload, ulong id) - { - if (state.MessageId == 0) - { - _sawmill.Warning("Failed to deliver update to custom vote webhook: message ID was zero. This likely indicates a previous connection error sending the original message."); - return; - } - - DebugTools.Assert(state.Identifier != default); - - try - { - await _discord.EditMessage(state.Identifier, id, payload); - } - catch (Exception e) - { - _sawmill.Error($"Error while updating vote webhook on Discord: {e}"); - } - } - - private sealed class WebhookState - { - public required string WebhookUrl; - public required WebhookPayload Payload; - public WebhookIdentifier Identifier; - public ulong MessageId; - } } [AnyCommand] diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index 14bb760f40..a4f315d62c 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -465,6 +465,12 @@ namespace Content.Shared.CCVar public static readonly CVarDef DiscordVoteWebhook = CVarDef.Create("discord.vote_webhook", string.Empty, CVar.SERVERONLY); + /// + /// URL of the Discord webhook which will relay all votekick votes. If left empty, disables the webhook. + /// + public static readonly CVarDef DiscordVotekickWebhook = + CVarDef.Create("discord.votekick_webhook", string.Empty, CVar.SERVERONLY); + /// URL of the Discord webhook which will relay round restart messages. /// public static readonly CVarDef DiscordRoundUpdateWebhook = diff --git a/Resources/Locale/en-US/administration/commands/custom-vote-command.ftl b/Resources/Locale/en-US/administration/commands/custom-vote-command.ftl deleted file mode 100644 index 221f0629a5..0000000000 --- a/Resources/Locale/en-US/administration/commands/custom-vote-command.ftl +++ /dev/null @@ -1,5 +0,0 @@ -custom-vote-webhook-name = Custom Vote Held -custom-vote-webhook-footer = server: { $serverName }, round: { $roundId } { $runLevel } -custom-vote-webhook-cancelled = **Vote cancelled** -custom-vote-webhook-option-pending = TBD -custom-vote-webhook-option-cancelled = N/A diff --git a/Resources/Locale/en-US/discord/vote-notifications.ftl b/Resources/Locale/en-US/discord/vote-notifications.ftl new file mode 100644 index 0000000000..f6779cac83 --- /dev/null +++ b/Resources/Locale/en-US/discord/vote-notifications.ftl @@ -0,0 +1,11 @@ +custom-vote-webhook-name = Custom Vote Held +custom-vote-webhook-footer = server: { $serverName }, round: { $roundId } { $runLevel } +custom-vote-webhook-cancelled = **Vote cancelled** +custom-vote-webhook-option-pending = TBD +custom-vote-webhook-option-cancelled = N/A + +votekick-webhook-name = Votekick Held +votekick-webhook-description = Initiator: { $initiator }; Target: { $target } +votekick-webhook-cancelled-admin-online = **Vote cancelled due to admins online** +votekick-webhook-cancelled-admin-target = **Vote cancelled due to target being admin** +votekick-webhook-cancelled-antag-target = **Vote cancelled due to target being antag**