diff --git a/Content.Server/Administration/Managers/BanManager.cs b/Content.Server/Administration/Managers/BanManager.cs index 4640c63dbd2..6d6580152d6 100644 --- a/Content.Server/Administration/Managers/BanManager.cs +++ b/Content.Server/Administration/Managers/BanManager.cs @@ -5,6 +5,7 @@ using Content.Server.Chat.Managers; using Content.Server.Database; using Content.Server.GameTicking; +using Content.Shared.GameTicking; using Content.Shared.Database; using Content.Shared.Players; using Content.Shared.Players.PlayTimeTracking; @@ -25,6 +26,7 @@ public sealed class BanManager : IBanManager, IPostInjectInit [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly IEntitySystemManager _systems = default!; + [Dependency] private readonly IEntityManager _entityManager = default!; [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly ILocalizationManager _localizationManager = default!; [Dependency] private readonly IChatManager _chat = default!; @@ -167,6 +169,8 @@ public async void CreateServerBan(NetUserId? target, string? targetUsername, Net _sawmill.Info(logMessage); _chat.SendAdminAlert(logMessage); + _entityManager.EventBus.RaiseEvent(EventSource.Local, new BanEvent(targetUsername ?? Loc.GetString("system-user"), expires, reason, severity, adminName)); + // If we're not banning a player we don't care about disconnecting people if (target == null) return; @@ -185,7 +189,7 @@ public async void CreateServerBan(NetUserId? target, string? targetUsername, Net // Removing it will clutter the note list. Please also make sure that department bans are applied to roles with the same DateTimeOffset. public async void CreateRoleBan(NetUserId? target, string? targetUsername, NetUserId? banningAdmin, (IPAddress, int)? addressRange, ImmutableArray? hwid, string role, uint? minutes, NoteSeverity severity, string reason, DateTimeOffset timeOfBan) { - if (!_prototypeManager.TryIndex(role, out JobPrototype? _)) + if (!_prototypeManager.TryIndex(role, out JobPrototype? jobPrototype)) { throw new ArgumentException($"Invalid role '{role}'", nameof(role)); } @@ -198,6 +202,9 @@ public async void CreateRoleBan(NetUserId? target, string? targetUsername, NetUs } _systems.TryGetEntitySystem(out GameTicker? ticker); + var adminName = banningAdmin == null + ? Loc.GetString("system-user") + : (await _db.GetPlayerRecordByUserId(banningAdmin.Value))?.LastSeenUserName ?? Loc.GetString("system-user"); int? roundId = ticker == null || ticker.RoundId == 0 ? null : ticker.RoundId; var playtime = target == null ? TimeSpan.Zero : (await _db.GetPlayTimes(target.Value)).Find(p => p.Tracker == PlayTimeTrackingShared.TrackerOverall)?.TimeSpent ?? TimeSpan.Zero; @@ -229,6 +236,7 @@ public async void CreateRoleBan(NetUserId? target, string? targetUsername, NetUs { SendRoleBans(target.Value); } + _entityManager.EventBus.RaiseEvent(EventSource.Local, new JobBanEvent(targetUsername ?? Loc.GetString("system-user"), expires, jobPrototype, reason, severity, adminName)); } public HashSet? GetJobBans(NetUserId playerUserId) diff --git a/Content.Server/Andromeda/BansNotificationSystem/BansNotificationSystem.cs b/Content.Server/Andromeda/BansNotificationSystem/BansNotificationSystem.cs new file mode 100644 index 00000000000..541ccd05d6e --- /dev/null +++ b/Content.Server/Andromeda/BansNotificationSystem/BansNotificationSystem.cs @@ -0,0 +1,259 @@ +using Content.Shared.CCVar; +using Content.Shared.Andromeda.CCVar; +using Content.Shared.Database; +using Content.Shared.GameTicking; +using Content.Shared.Roles; +using Robust.Shared; +using Robust.Shared.Configuration; +using System.Net.Http; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text; + +namespace Content.Server.Andromeda.BansNotifications +{ + + /// + /// Listen game events and send notifications to Discord + /// + /* + public interface IBansNotificationsSystem + { + void RaiseLocalBanEvent(string username, DateTimeOffset? expires, string reason, NoteSeverity severity, string adminusername); + void RaiseLocalJobBanEvent(string username, DateTimeOffset? expires, JobPrototype job, string reason, NoteSeverity severity, string adminusername); + void RaiseLocalDepartmentBanEvent(string username, DateTimeOffset? expires, DepartmentPrototype department, string reason, NoteSeverity severity, string adminusername); + } + */ + + public sealed class BansNotificationsSystem : EntitySystem + { + [Dependency] private readonly IConfigurationManager _config = default!; + private ISawmill _sawmill = default!; + private readonly HttpClient _httpClient = new(); + private string _webhookUrl = String.Empty; + private string _serverName = String.Empty; + + public override void Initialize() + { + _sawmill = Logger.GetSawmill("bans_notifications"); + SubscribeLocalEvent(OnBan); + SubscribeLocalEvent(OnJobBan); + SubscribeLocalEvent(OnDepartmentBan); + _config.OnValueChanged(AndromedaCCVars.DiscordBanWebhook, value => _webhookUrl = value, true); + _config.OnValueChanged(CVars.GameHostName, value => _serverName = value, true); + } +/* + public void RaiseLocalBanEvent(string username, DateTimeOffset? expires, string reason, NoteSeverity severity, string adminusername) + { + RaiseLocalEvent(new BanEvent(username, expires, reason, severity, adminusername)); + } + + public void RaiseLocalJobBanEvent(string username, DateTimeOffset? expires, JobPrototype job, string reason, NoteSeverity severity, string adminusername) + { + RaiseLocalEvent(new JobBanEvent(username, expires, job, reason, severity, adminusername)); + } + + public void RaiseLocalDepartmentBanEvent(string username, DateTimeOffset? expires, DepartmentPrototype department, string reason, NoteSeverity severity, string adminusername) + { + RaiseLocalEvent(new DepartmentBanEvent(username, expires, department, reason, severity, adminusername)); + } +*/ + private async void SendDiscordMessage(WebhookPayload payload) + { + var request = await _httpClient.PostAsync(_webhookUrl, + new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); + + _sawmill.Debug($"Discord webhook json: {JsonSerializer.Serialize(payload)}"); + + var content = await request.Content.ReadAsStringAsync(); + if (!request.IsSuccessStatusCode) + { + _sawmill.Error($"Discord returned bad status code when posting message: {request.StatusCode}\nResponse: {content}"); + return; + } + } + + public void OnBan(BanEvent e) + { + if (String.IsNullOrEmpty(_webhookUrl)) + return; + + var expires = e.Expires == null ? Loc.GetString("discord-permanent") : Loc.GetString("discord-expires-at", ("date", e.Expires)); + var message = Loc.GetString("discord-ban-msg", + ("username", e.Username), + ("expires", expires), + ("reason", e.Reason)); + + var color = e.Severity switch + { + NoteSeverity.None => 0x6aa84f, + NoteSeverity.Minor => 0x45818e, + NoteSeverity.Medium => 0xf1c232, + NoteSeverity.High => 0xff0000, + _ => 0xff0000, + }; + + var payload = new WebhookPayload + { + + Username = _serverName, + /* + AvatarUrl = string.IsNullOrWhiteSpace(_avatarUrl) ? null : _avatarUrl, + */ + Embeds = new List + { + new() + { + Description = message, + Color = color, + Footer = new EmbedFooter + { + Text = $"{e.AdminUsername}", + /* + IconUrl = string.IsNullOrWhiteSpace(_footerIconUrl) ? null : _footerIconUrl + */ + }, + }, + }, + }; + + SendDiscordMessage(payload); + } + + public void OnJobBan(JobBanEvent e) + { + if (String.IsNullOrEmpty(_webhookUrl)) + return; + + var expires = e.Expires == null ? Loc.GetString("discord-permanent") : Loc.GetString("discord-expires-at", ("date", e.Expires)); + var message = Loc.GetString("discord-jobban-msg", + ("username", e.Username), + ("role", e.Job.LocalizedName), + ("expires", expires), + ("reason", e.Reason)); + + + var color = e.Severity switch + { + NoteSeverity.None => 0x6aa84f, + NoteSeverity.Minor => 0x45818e, + NoteSeverity.Medium => 0xf1c232, + NoteSeverity.High => 0xff0000, + _ => 0xff0000, + }; + + var payload = new WebhookPayload + { + Username = _serverName, + /* + AvatarUrl = string.IsNullOrWhiteSpace(_avatarUrl) ? null : _avatarUrl, + */ + Embeds = new List + { + new() + { + Description = message, + Color = color, + Footer = new EmbedFooter + { + Text = $"{e.AdminUsername}", + /* + IconUrl = string.IsNullOrWhiteSpace(_footerIconUrl) ? null : _footerIconUrl + */ + }, + }, + }, + }; + + SendDiscordMessage(payload); + } + + public void OnDepartmentBan(DepartmentBanEvent e) + { + if (String.IsNullOrEmpty(_webhookUrl)) + return; +/* + var payload = new WebhookPayload(); + var departamentLocName = Loc.GetString(string.Concat("department-", e.Department.ID)); + var expires = e.Expires == null ? Loc.GetString("discord-permanent") : Loc.GetString("discord-expires-at", ("date", e.Expires)); + var text = Loc.GetString("discord-departmentban-msg", + ("username", e.Username), + ("department", departamentLocName), + ("expires", expires), + ("reason", e.Reason)); + + payload.Content = text; + SendDiscordMessage(payload); +*/ + } + + 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/Andromeda/BansNotificationSystem/GameTicking/Events/BanEvent.cs b/Content.Server/Andromeda/BansNotificationSystem/GameTicking/Events/BanEvent.cs new file mode 100644 index 00000000000..ade696fed1f --- /dev/null +++ b/Content.Server/Andromeda/BansNotificationSystem/GameTicking/Events/BanEvent.cs @@ -0,0 +1,21 @@ +using Content.Shared.Database; + +namespace Content.Shared.GameTicking; + +public sealed class BanEvent : EntityEventArgs +{ + public string Username { get; } + public DateTimeOffset? Expires { get; } + public string Reason { get; } + public NoteSeverity Severity { get; } + public string AdminUsername { get; } + + public BanEvent(string username, DateTimeOffset? expires, string reason, NoteSeverity severity, string adminusername) + { + Username = username; + Expires = expires; + Reason = reason; + Severity = severity; + AdminUsername = adminusername; + } +} diff --git a/Content.Server/Andromeda/BansNotificationSystem/GameTicking/Events/DepartmentBanEvent.cs b/Content.Server/Andromeda/BansNotificationSystem/GameTicking/Events/DepartmentBanEvent.cs new file mode 100644 index 00000000000..d3e396ec75a --- /dev/null +++ b/Content.Server/Andromeda/BansNotificationSystem/GameTicking/Events/DepartmentBanEvent.cs @@ -0,0 +1,25 @@ +using Content.Shared.Database; +using Content.Shared.Roles; + +namespace Content.Shared.GameTicking; + +public sealed class DepartmentBanEvent : EntityEventArgs +{ + public string Username { get; } + public DepartmentPrototype Department { get; } + public DateTimeOffset? Expires { get; } + public string Reason { get; } + public NoteSeverity Severity { get; } + public string AdminUsername { get; } + + + public DepartmentBanEvent(string username, DateTimeOffset? expires, DepartmentPrototype department, string reason, NoteSeverity severity, string adminusername) + { + Username = username; + Department = department; + Expires = expires; + Reason = reason; + Severity = severity; + AdminUsername = adminusername; + } +} diff --git a/Content.Server/Andromeda/BansNotificationSystem/GameTicking/Events/JobBanEvent.cs b/Content.Server/Andromeda/BansNotificationSystem/GameTicking/Events/JobBanEvent.cs new file mode 100644 index 00000000000..9ce2d6cd3d8 --- /dev/null +++ b/Content.Server/Andromeda/BansNotificationSystem/GameTicking/Events/JobBanEvent.cs @@ -0,0 +1,24 @@ +using Content.Shared.Database; +using Content.Shared.Roles; + +namespace Content.Shared.GameTicking; + +public sealed class JobBanEvent : EntityEventArgs +{ + public string Username { get; } + public JobPrototype Job { get; } + public DateTimeOffset? Expires { get; } + public string Reason { get; } + public NoteSeverity Severity { get; } + public string AdminUsername { get; } + + public JobBanEvent(string username, DateTimeOffset? expires, JobPrototype job, string reason, NoteSeverity severity, string adminusername) + { + Username = username; + Job = job; + Expires = expires; + Reason = reason; + Severity = severity; + AdminUsername = adminusername; + } +} diff --git a/Content.Server/Andromeda/BansNotificationsSystem.cs b/Content.Server/Andromeda/BansNotificationsSystem.cs deleted file mode 100644 index e6a5583b581..00000000000 --- a/Content.Server/Andromeda/BansNotificationsSystem.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Content.Shared.CCVar; -using Content.Shared.GameTicking; -using Robust.Shared.Configuration; - -namespace Content.Server.Andromeda; - -/// -/// Listen game events and send notifications to Discord -/// - -public sealed class BansNotificationsSystem : EntitySystem -{ - [Dependency] private readonly IConfigurationManager _config = default!; - - private ISawmill _sawmill = default!; - private readonly HttpClient _httpClient = new(); - - private string _webhookUrl = string.Empty; - - public override void Initialize() - { - base.Initialize(); - _config.OnValueChanged(CCVars.DiscordBanWebhook, OnWebhookChanged, true); - SubscribeLocalEvent(OnBan); - } - - private async void SendDiscordMessage(WebhookPayload payload) - { - var request = await _httpClient.PostAsync(_webhookUrl, - 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}"); - } - } - - private void OnWebhookChanged(string url) - { - _webhookUrl = url; - } - - public void OnBan(BanEvent e) - { - if (string.IsNullOrEmpty(_webhookUrl)) - return; - - var payload = new WebhookPayload(); - var text = Loc.GetString("discord-ban-msg", - ("adminnick", e.AdminNick), - ("username", e.Username), - ("expires", e.Expires == null ? "навсегда" : $"до {e.Expires}"), - ("reason", e.Reason)); - - payload.Content = text; - - SendDiscordMessage(payload); - } - - public void NotifyBan(string adminNick, string username, string reason, DateTimeOffset? expires = null) - { - RaiseLocalEvent(new BanEvent(adminNick, username, expires, reason)); - } - - private struct WebhookPayload - { - [JsonPropertyName("content")] - public string Content { get; set; } = ""; - - public Dictionary AllowedMentions { get; set; } = - new() - { - { "parse", Array.Empty() } - }; - - public WebhookPayload() - { - } - } -} diff --git a/Content.Shared/Andromeda/CCVar/CCVars.cs b/Content.Shared/Andromeda/CCVar/CCVars.cs new file mode 100644 index 00000000000..6580f5deec7 --- /dev/null +++ b/Content.Shared/Andromeda/CCVar/CCVars.cs @@ -0,0 +1,31 @@ +using Robust.Shared; +using Robust.Shared.Configuration; +using Robust.Shared.Utility; + +namespace Content.Shared.Andromeda.CCVar +{ + // ReSharper disable once InconsistentNaming + [CVarDefs] + public sealed class AndromedaCCVars : CVars + { + /* + * Server + */ + + + /* + * Game + */ + + + /* + * Discord + */ + + /// + /// URL of the Discord webhook which will show bans in game. + /// + public static readonly CVarDef DiscordBanWebhook = + CVarDef.Create("discord.ban_webhook", string.Empty, CVar.SERVERONLY); + } +} diff --git a/Resources/Locale/en-US/discord.ftl b/Resources/Locale/en-US/discord.ftl new file mode 100644 index 00000000000..51de2c3dbf0 --- /dev/null +++ b/Resources/Locale/en-US/discord.ftl @@ -0,0 +1,5 @@ +discord-expires-at = until { $date } +discord-permanent = permanently +discord-ban-msg = Player { $username } has banned { $expires } for reason: { $reason } +discord-jobban-msg = Player { $username } has banned for role { $role } { $expires } for reason: { $reason } +discord-departmentban-msg = Player { $username } has banned for department { $department } { $expires } for reason: { $reason } diff --git a/Resources/Locale/ru-RU/discordRU.ftl b/Resources/Locale/ru-RU/discordRU.ftl new file mode 100644 index 00000000000..974039ace82 --- /dev/null +++ b/Resources/Locale/ru-RU/discordRU.ftl @@ -0,0 +1,5 @@ +discord-expires-at = до { $date } +discord-permanent = навсегда +discord-ban-msg = Игрок { $username } забанен { $expires } по причине: { $reason } +discord-jobban-msg = Игроку { $username } заблокирована роль { $role } { $expires } по причине: { $reason } +discord-departmentban-msg = Игроку { $username } заблокирован департамент { $department } { $expires } по причине: { $reason }