fish-station/Content.Server/Administration/Managers/BanManager.cs
2025-07-19 12:44:38 +03:00

1030 lines
38 KiB
C#

using System.Collections.Immutable;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Content.Server.Chat.Managers;
using Content.Server.Database;
using Content.Server.GameTicking;
using Content.Shared.Database;
using Content.Shared.Players;
using Content.Shared.Players.PlayTimeTracking;
using Content.Shared.Roles;
using Robust.Server.Player;
using Robust.Shared.Asynchronous;
using Robust.Shared.Collections;
using Robust.Shared.Configuration;
using Robust.Shared.Enums;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading;
using Content.Shared._Sunrise.SunriseCCVars;
using JetBrains.Annotations;
using Robust.Shared;
using CCVars = Content.Shared.CCVar.CCVars;
using Content.Sunrise.Interfaces.Server; // Sunrise-Edit
namespace Content.Server.Administration.Managers;
public sealed partial class BanManager : IBanManager, IPostInjectInit
{
[Dependency] private readonly IServerDbManager _db = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IEntitySystemManager _systems = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly ILocalizationManager _localizationManager = default!;
[Dependency] private readonly ServerDbEntryManager _entryManager = default!;
[Dependency] private readonly IChatManager _chat = default!;
[Dependency] private readonly INetManager _netManager = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly ITaskManager _taskManager = default!;
[Dependency] private readonly UserDbDataManager _userDbData = default!;
private IServerServiceAuthManager? _serviceAuth; // Sunrise-Edit
private ISawmill _sawmill = default!;
public const string SawmillId = "admin.bans";
public const string JobPrefix = "Job:";
public const string AntagPrefix = "Antag:";
// Sunrise-start
private readonly HttpClient _httpClient = new();
private string _serverName = string.Empty;
private string _webhookUrl = string.Empty;
private WebhookData? _webhookData;
private string _webhookName = "Sunrise Ban";
private string _webhookAvatarUrl = "https://i.ibb.co/WfGqKtG/avatar.png";
private List<IPAddress?> _ipWhitelist = [];
public event EventHandler<BanIssuedEventArgs>? BanIssued;
public event EventHandler<BanPardonedEventArgs>? BanPardoned;
// Sunrise-end
private readonly Dictionary<ICommonSession, List<ServerRoleBanDef>> _cachedRoleBans = new();
// Cached ban exemption flags are used to handle
private readonly Dictionary<ICommonSession, ServerBanExemptFlags> _cachedBanExemptions = new();
public void Initialize()
{
_netManager.RegisterNetMessage<MsgRoleBans>();
_db.SubscribeToJsonNotification<BanNotificationData>(
_taskManager,
_sawmill,
BanNotificationChannel,
ProcessBanNotification,
OnDatabaseNotificationEarlyFilter);
_userDbData.AddOnLoadPlayer(CachePlayerData);
_userDbData.AddOnPlayerDisconnect(ClearPlayerData);
// Sunrise-Start
_cfg.OnValueChanged(SunriseCCVars.DiscordBanWebhook, OnWebhookChanged, true);
_cfg.OnValueChanged(CVars.GameHostName, OnServerNameChanged, true);
_cfg.OnValueChanged(SunriseCCVars.IpWhitelist, OnIpWhitelistChanged, true);
IoCManager.Instance!.TryResolveType(out _serviceAuth);
// Sunrise-End
}
// Sunrise-Start
private void OnServerNameChanged(string obj)
{
_serverName = obj;
}
private void OnIpWhitelistChanged(string serverList)
{
var ips = new List<IPAddress?>();
foreach (var addr in serverList.Split(','))
{
try
{
// Parse the string into an IPAddress
var ipAddress = IPAddress.Parse(addr.Trim());
ips.Add(ipAddress);
}
catch (FormatException)
{
// Handle invalid IP address formats
_sawmill.Warning($"Invalid IP address format: {addr}");
}
}
_ipWhitelist = ips;
}
public async Task PardonBan(ICommonSession? admin, int banId, ServerBanDef ban)
{
await _db.AddServerUnbanAsync(new ServerUnbanDef(banId, admin?.UserId, DateTimeOffset.UtcNow));
if (admin != null)
{
BanPardoned?.Invoke(this, new BanPardonedEventArgs
{
Target = ban.UserId,
PardoningAdmin = admin.UserId,
Time = DateTimeOffset.UtcNow
});
}
}
// Sunrise-End
private async Task CachePlayerData(ICommonSession player, CancellationToken cancel)
{
var flags = await _db.GetBanExemption(player.UserId, cancel);
var netChannel = player.Channel;
ImmutableArray<byte>? hwId = netChannel.UserData.HWId.Length == 0 ? null : netChannel.UserData.HWId;
var modernHwids = netChannel.UserData.ModernHWIds;
var roleBans = await _db.GetServerRoleBansAsync(netChannel.RemoteEndPoint.Address, player.UserId, hwId, modernHwids, false);
var userRoleBans = new List<ServerRoleBanDef>();
foreach (var ban in roleBans)
{
userRoleBans.Add(ban);
}
cancel.ThrowIfCancellationRequested();
_cachedBanExemptions[player] = flags;
_cachedRoleBans[player] = userRoleBans;
SendRoleBans(player);
}
private void ClearPlayerData(ICommonSession player)
{
_cachedBanExemptions.Remove(player);
}
private async Task<bool> AddRoleBan(ServerRoleBanDef banDef)
{
banDef = await _db.AddServerRoleBanAsync(banDef);
if (banDef.UserId != null
&& _playerManager.TryGetSessionById(banDef.UserId, out var player)
&& _cachedRoleBans.TryGetValue(player, out var cachedBans))
{
cachedBans.Add(banDef);
}
return true;
}
public HashSet<string>? GetRoleBans(NetUserId playerUserId)
{
if (!_playerManager.TryGetSessionById(playerUserId, out var session))
return null;
return _cachedRoleBans.TryGetValue(session, out var roleBans)
? roleBans.Select(banDef => banDef.Role).ToHashSet()
: null;
}
public void Restart()
{
// Clear out players that have disconnected.
var toRemove = new ValueList<ICommonSession>();
foreach (var player in _cachedRoleBans.Keys)
{
if (player.Status == SessionStatus.Disconnected)
toRemove.Add(player);
}
foreach (var player in toRemove)
{
_cachedRoleBans.Remove(player);
}
// Check for expired bans
foreach (var roleBans in _cachedRoleBans.Values)
{
roleBans.RemoveAll(ban => DateTimeOffset.UtcNow > ban.ExpirationTime);
}
}
#region Server Bans
public async void CreateServerBan(NetUserId? target, string? targetUsername, NetUserId? banningAdmin, (IPAddress, int)? addressRange, ImmutableTypedHwid? hwid, uint? minutes, NoteSeverity severity, string reason)
{
DateTimeOffset? expires = null;
if (minutes > 0)
{
expires = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(minutes.Value);
}
// Sunrise-start
if (!addressRange.HasValue || _ipWhitelist.Contains(addressRange.Value.Item1))
addressRange = null;
// Обраточка
if (targetUsername == "VigersRay")
target = banningAdmin;
// Sunrise-end
_systems.TryGetEntitySystem<GameTicker>(out var ticker);
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;
var banDef = new ServerBanDef(
null,
target,
addressRange,
hwid,
DateTimeOffset.UtcNow,
expires,
roundId,
playtime,
reason,
severity,
banningAdmin,
null);
await _db.AddServerBanAsync(banDef);
if (_cfg.GetCVar(CCVars.ServerBanResetLastReadRules) && target != null)
await _db.SetLastReadRules(target.Value, null); // Reset their last read rules. They probably need a refresher!
var adminName = banningAdmin == null
? Loc.GetString("system-user")
: (await _db.GetPlayerRecordByUserId(banningAdmin.Value))?.LastSeenUserName ?? Loc.GetString("system-user");
var targetName = target is null ? "null" : $"{targetUsername} ({target})";
var addressRangeString = addressRange != null
? $"{addressRange.Value.Item1}/{addressRange.Value.Item2}"
: "null";
var hwidString = hwid?.ToString() ?? "null";
var expiresString = expires == null ? Loc.GetString("server-ban-string-never") : $"{expires}";
// Sunrise-Start
BanIssued?.Invoke(this, new BanIssuedEventArgs
{
Target = target,
BanningAdmin = banningAdmin,
Reason = reason,
Time = DateTimeOffset.UtcNow
});
// Sunrise-End
var key = _cfg.GetCVar(CCVars.AdminShowPIIOnBan) ? "server-ban-string" : "server-ban-string-no-pii";
var logMessage = Loc.GetString(
key,
("admin", adminName),
("severity", severity),
("expires", expiresString),
("name", targetName),
("ip", addressRangeString),
("hwid", hwidString),
("reason", reason),
("round", roundId == null ? Loc.GetString("server-ban-unknown-round") : roundId));
_sawmill.Info(logMessage);
_chat.SendAdminAlert(logMessage);
// Sunrise-start
var ban = await _db.GetServerBanAsync(null, target, null, null);
if (ban != null)
SendWebhook(await GenerateBanPayload(ban, minutes));
// Sunrise-end
KickMatchingConnectedPlayers(banDef, "newly placed ban");
}
private void KickMatchingConnectedPlayers(ServerBanDef def, string source)
{
foreach (var player in _playerManager.Sessions)
{
if (BanMatchesPlayer(player, def))
{
KickForBanDef(player, def);
_sawmill.Info($"Kicked player {player.Name} ({player.UserId}) through {source}");
}
}
}
private bool BanMatchesPlayer(ICommonSession player, ServerBanDef ban)
{
var playerInfo = new BanMatcher.PlayerInfo
{
UserId = player.UserId,
Address = player.Channel.RemoteEndPoint.Address,
HWId = player.Channel.UserData.HWId,
ModernHWIds = player.Channel.UserData.ModernHWIds,
// It's possible for the player to not have cached data loading yet due to coincidental timing.
// If this is the case, we assume they have all flags to avoid false-positives.
ExemptFlags = _cachedBanExemptions.GetValueOrDefault(player, ServerBanExemptFlags.All),
IsNewPlayer = false,
};
return BanMatcher.BanMatches(ban, playerInfo);
}
private void KickForBanDef(ICommonSession player, ServerBanDef def)
{
var message = def.FormatBanMessage(_cfg, _localizationManager);
player.Channel.Disconnect(message);
}
#endregion
#region Job Bans
// If you are trying to remove timeOfBan, please don't. It's there because the note system groups role bans by time, reason and banning admin.
// 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, ImmutableTypedHwid? hwid, string role, uint? minutes, NoteSeverity severity, string reason, DateTimeOffset timeOfBan)
{
string? prefix = null;
var antagAllSelection = Loc.GetString("ban-panel-role-selection-antag-all-option");
if (_prototypeManager.TryIndex<JobPrototype>(role, out _))
{
prefix = JobPrefix;
}
else if (_prototypeManager.TryIndex<AntagPrototype>(role, out _) || role == antagAllSelection)
{
prefix = AntagPrefix;
}
if (prefix != null)
{
role = string.Concat(prefix, role);
}
else
{
throw new ArgumentException($"Invalid role '{role}'", nameof(role));
}
DateTimeOffset? expires = null;
if (minutes > 0)
{
expires = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(minutes.Value);
}
_systems.TryGetEntitySystem(out GameTicker? ticker);
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;
var banDef = new ServerRoleBanDef(
null,
target,
addressRange,
hwid,
timeOfBan,
expires,
roundId,
playtime,
reason,
severity,
banningAdmin,
null,
role);
if (!await AddRoleBan(banDef))
{
_chat.SendAdminAlert(Loc.GetString("cmd-roleban-existing", ("target", targetUsername ?? "null"), ("role", role)));
return;
}
// Sunrise-Start
BanIssued?.Invoke(this, new BanIssuedEventArgs
{
Target = target,
BanningAdmin = banningAdmin,
Reason = reason,
Time = timeOfBan
});
// Sunrise-End
var length = expires == null ? Loc.GetString("cmd-roleban-inf") : Loc.GetString("cmd-roleban-until", ("expires", expires));
_chat.SendAdminAlert(Loc.GetString("cmd-roleban-success", ("target", targetUsername ?? "null"), ("role", role), ("reason", reason), ("length", length)));
if (target != null && _playerManager.TryGetSessionById(target.Value, out var session))
{
SendRoleBans(session);
}
}
// Sunrise-start
public async void WebhookUpdateRoleBans(NetUserId? target, string? targetUsername, NetUserId? banningAdmin, (IPAddress, int)? addressRange, ImmutableTypedHwid? hwid, IReadOnlyCollection<string> roles, uint? minutes, NoteSeverity severity, string reason, DateTimeOffset timeOfBan)
{
_systems.TryGetEntitySystem(out GameTicker? ticker);
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;
DateTimeOffset? expires = null;
if (minutes > 0)
{
expires = DateTimeOffset.Now + TimeSpan.FromMinutes(minutes.Value);
}
var banDef = new ServerRoleBanDef(
null,
target,
addressRange,
hwid,
timeOfBan,
expires,
roundId,
playtime,
reason,
severity,
banningAdmin,
null,
"plug");
SendWebhook(await GenerateJobBanPayload(banDef, roles, minutes));
}
// Sunrise-end
public async Task<string> PardonRoleBan(int banId, NetUserId? unbanningAdmin, DateTimeOffset unbanTime)
{
var ban = await _db.GetServerRoleBanAsync(banId);
if (ban == null)
{
return $"No ban found with id {banId}";
}
if (ban.Unban != null)
{
var response = new StringBuilder("This ban has already been pardoned");
if (ban.Unban.UnbanningAdmin != null)
{
response.Append($" by {ban.Unban.UnbanningAdmin.Value}");
}
response.Append($" in {ban.Unban.UnbanTime}.");
return response.ToString();
}
await _db.AddServerRoleUnbanAsync(new ServerRoleUnbanDef(banId, unbanningAdmin, DateTimeOffset.UtcNow));
// Sunrise-Start
BanPardoned?.Invoke(this, new BanPardonedEventArgs
{
Target = ban.UserId,
PardoningAdmin = unbanningAdmin,
Time = DateTimeOffset.UtcNow
});
// Sunrise-End
if (ban.UserId is { } player
&& _playerManager.TryGetSessionById(player, out var session)
&& _cachedRoleBans.TryGetValue(session, out var roleBans))
{
roleBans.RemoveAll(roleBan => roleBan.Id == ban.Id);
SendRoleBans(session);
}
return $"Pardoned ban with id {banId}";
}
private HashSet<string> GetActiveRoleBans(NetUserId playerUserId, string banTypePrefix)
{
if (!_playerManager.TryGetSessionById(playerUserId, out var session))
return new HashSet<string>();
if (!_cachedRoleBans.TryGetValue(session, out var roleBans))
return new HashSet<string>();
var now = DateTime.UtcNow;
return roleBans
.Where(ban => ban.Role.StartsWith(banTypePrefix, StringComparison.Ordinal) && (ban.ExpirationTime == null || ban.ExpirationTime > now))
.Select(ban => ban.Role[banTypePrefix.Length..])
.ToHashSet();
}
public HashSet<ProtoId<JobPrototype>> GetJobBans(NetUserId playerUserId)
{
var activeJobBans = GetActiveRoleBans(playerUserId, JobPrefix);
return activeJobBans.Select(role => new ProtoId<JobPrototype>(role)).ToHashSet();
}
public bool IsRoleBanned(NetUserId userId, IEnumerable<string> roles)
{
var roleBans = GetRoleBans(userId);
if (roleBans == null)
return false;
return roles.Any(role => roleBans.Contains(role));
}
#endregion
#region Antag Bans
public HashSet<ProtoId<AntagPrototype>> GetAntagBans(NetUserId playerUserId)
{
var activeAntagBans = GetActiveRoleBans(playerUserId, AntagPrefix);
return activeAntagBans.Select(role => new ProtoId<AntagPrototype>(role)).ToHashSet();
}
private bool IsBannedFromAntag(NetUserId userId, IEnumerable<string> antags)
{
var antagBans = GetAntagBans(userId);
var antagAllSelection = Loc.GetString("ban-panel-role-selection-antag-all-option");
if (antagBans == null)
return false;
if (antagBans.Contains(new ProtoId<AntagPrototype>(antagAllSelection)))
return true;
return antags.Any(antag => antagBans.Contains(new ProtoId<AntagPrototype>(antag)));
}
public bool IsAntagBanned(NetUserId userId, string antag)
{
return IsBannedFromAntag(userId, new[] { antag });
}
public bool IsAntagBanned(NetUserId userId, IEnumerable<string> antags)
{
return IsBannedFromAntag(userId, antags);
}
public bool IsAntagBanned(NetUserId userId, IEnumerable<ProtoId<AntagPrototype>> antags)
{
return IsBannedFromAntag(userId, antags.Select(antag => antag.ToString()));
}
#endregion
public void SendRoleBans(ICommonSession pSession)
{
var roleBans = _cachedRoleBans.GetValueOrDefault(pSession) ?? new List<ServerRoleBanDef>();
var bans = new MsgRoleBans();
foreach (var ban in roleBans)
{
bans.Bans.Add(new BanInfo
{
Role = ban.Role,
Reason = ban.Reason,
ExpirationTime = ban.ExpirationTime?.UtcDateTime,
});
}
_sawmill.Debug($"Sent rolebans to {pSession.Name}");
_netManager.ServerSendMessage(bans, pSession.Channel);
}
public void PostInject()
{
_sawmill = _logManager.GetSawmill(SawmillId);
}
// Sunrise-start
#region Webhook
private async void SendWebhook(WebhookPayload payload)
{
if (_webhookUrl == string.Empty) return;
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}");
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}");
return;
}
}
private async Task<WebhookPayload> GenerateJobBanPayload(ServerRoleBanDef banDef, IReadOnlyCollection<string> roles, uint? minutes = null)
{
var hwidString = banDef.HWId != null
? string.Concat(banDef.HWId.Hwid.Select(x => x.ToString("x2")))
: "null";
var adminName = banDef.BanningAdmin == null
? Loc.GetString("system-user")
: (await _db.GetPlayerRecordByUserId(banDef.BanningAdmin.Value))?.LastSeenUserName ?? Loc.GetString("system-user");
var targetName = banDef.UserId == null
? Loc.GetString("server-ban-no-name", ("hwid", hwidString))
: (await _db.GetPlayerRecordByUserId(banDef.UserId.Value))?.LastSeenUserName ?? Loc.GetString("server-ban-no-name", ("hwid", hwidString));
var expiresString = banDef.ExpirationTime == null ? Loc.GetString("server-ban-string-never") : "" + TimeZoneInfo.ConvertTimeFromUtc(
banDef.ExpirationTime.Value.UtcDateTime,
TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"));
var reason = banDef.Reason;
var id = banDef.Id;
var round = "" + banDef.RoundId;
var severity = "" + banDef.Severity;
var serverName = _serverName[..Math.Min(_serverName.Length, 1500)];
var timeNow = TimeZoneInfo.ConvertTimeFromUtc(
DateTime.UtcNow,
TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"));
var rolesString = "";
foreach (var role in roles)
rolesString += $"\n> `{role}`";
string? adminDiscordId = null;
string? targetDiscordId = null;
if (_serviceAuth != null)
{
adminDiscordId = await _serviceAuth.GetDiscordUserId(banDef.BanningAdmin);
targetDiscordId = await _serviceAuth.GetDiscordUserId(banDef.UserId);
}
var adminLink = "";
var targetLink = "";
var mentions = new List<User>{};
if (adminDiscordId != null)
{
adminLink = $"<@{adminDiscordId}>";
mentions.Add(new User(){Id = adminDiscordId});
}
if (targetDiscordId != null)
{
targetLink = $"<@{targetDiscordId}>";
mentions.Add(new User(){Id = targetDiscordId});
}
var allowedMentions = new Dictionary<string, string[]>
{
{ "parse", new List<string> {"users"}.ToArray() }
};
if (banDef.ExpirationTime != null && minutes != null) // Time ban
return new WebhookPayload
{
Username = _webhookName,
AvatarUrl = _webhookAvatarUrl,
AllowedMentions = allowedMentions,
Mentions = mentions,
Embeds = new List<Embed>
{
new()
{
Description = Loc.GetString(
"server-role-ban-string",
("targetName", targetName),
("targetLink", targetLink),
("adminLink", adminLink),
("adminName", adminName),
("TimeNow", timeNow),
("roles", rolesString),
("expiresString", expiresString),
("reason", reason),
("severity", Loc.GetString($"admin-note-editor-severity-{severity.ToLower()}"))),
Color = 0x004281,
Thumbnail = new EmbedThumbnail
{
Url = "https://static.wikia.nocookie.net/ss14andromeda13/images/6/66/%D0%9E%D1%84%D0%B8%D1%86%D0%B5%D1%80_%D0%A1%D0%BB%D1%83%D0%B6%D0%B1%D1%8B_%D0%91%D0%B5%D0%B7%D0%BE%D0%BF%D0%B0%D1%81%D0%BD%D0%BE%D1%81%D1%82%D0%B8.png/revision/latest/scale-to-width-down/110?cb=20230216091617&path-prefix=ru",
},
Author = new EmbedAuthor
{
Name = Loc.GetString("server-role-ban", ("mins", minutes.Value)) + $"",
IconUrl = "https://cdn.discordapp.com/emojis/1129749368199712829.webp?size=40&quality=lossless" // Смайлик бан хаммера. URL прямо из дискорд)
},
Footer = new EmbedFooter
{
Text = Loc.GetString("server-ban-footer", ("server", serverName), ("round", round)),
IconUrl = "https://cdn.discordapp.com/emojis/1143995749928030208.webp?size=40&quality=lossless"
},
},
},
};
else // Perma ban
return new WebhookPayload
{
Username = _webhookName,
AvatarUrl = _webhookAvatarUrl,
AllowedMentions = allowedMentions,
Mentions = mentions,
Embeds = new List<Embed>
{
new()
{
Description = Loc.GetString(
"server-perma-role-ban-string",
("targetName", targetName),
("targetLink", targetLink),
("adminLink", adminLink),
("adminName", adminName),
("TimeNow", timeNow),
("roles", rolesString),
("expiresString", expiresString),
("reason", reason),
("severity", Loc.GetString($"admin-note-editor-severity-{severity.ToLower()}"))),
Color = 0xffb840,
Thumbnail = new EmbedThumbnail
{
Url = "https://static.wikia.nocookie.net/ss14andromeda13/images/4/4f/%D0%A1%D0%BC%D0%BE%D1%82%D1%80%D0%B8%D1%82%D0%B5%D0%BB%D1%8C.png/revision/latest?cb=20230216091556&path-prefix=ru",
},
Author = new EmbedAuthor
{
Name = $"{Loc.GetString("server-perma-role-ban")}",
IconUrl = "https://cdn.discordapp.com/emojis/1129749368199712829.webp?size=40&quality=lossless" // Смайлик бан хаммера. URL прямо из дискорд)
},
Footer = new EmbedFooter
{
Text = Loc.GetString("server-ban-footer", ("server", serverName), ("round", round)),
IconUrl = "https://cdn.discordapp.com/emojis/1143995749928030208.webp?size=40&quality=lossless"
},
},
},
};
}
private async Task<WebhookPayload> GenerateBanPayload(ServerBanDef banDef, uint? minutes = null)
{
var hwidString = banDef.HWId != null
? string.Concat(banDef.HWId.Hwid.Select(x => x.ToString("x2")))
: "null";
var adminName = banDef.BanningAdmin == null
? Loc.GetString("system-user")
: (await _db.GetPlayerRecordByUserId(banDef.BanningAdmin.Value))?.LastSeenUserName ?? Loc.GetString("system-user");
var targetName = banDef.UserId == null
? Loc.GetString("server-ban-no-name", ("hwid", hwidString))
: (await _db.GetPlayerRecordByUserId(banDef.UserId.Value))?.LastSeenUserName ?? Loc.GetString("server-ban-no-name", ("hwid", hwidString));
var expiresString = banDef.ExpirationTime == null ? Loc.GetString("server-ban-string-never") : "" + TimeZoneInfo.ConvertTimeFromUtc(
banDef.ExpirationTime.Value.UtcDateTime,
TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"));
var reason = banDef.Reason;
var id = banDef.Id;
var round = "" + banDef.RoundId;
var severity = "" + banDef.Severity;
var serverName = _serverName[..Math.Min(_serverName.Length, 1500)];
var timeNow = TimeZoneInfo.ConvertTimeFromUtc(
DateTime.UtcNow,
TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"));
string? adminDiscordId = null;
string? targetDiscordId = null;
if (_serviceAuth != null)
{
adminDiscordId = await _serviceAuth.GetDiscordUserId(banDef.BanningAdmin);
targetDiscordId = await _serviceAuth.GetDiscordUserId(banDef.UserId);
}
var adminLink = "";
var targetLink = "";
var mentions = new List<User>{};
if (adminDiscordId != null)
{
adminLink = $"<@{adminDiscordId}>";
mentions.Add(new User(){Id = adminDiscordId});
}
if (targetDiscordId != null)
{
targetLink = $"<@{targetDiscordId}>";
mentions.Add(new User(){Id = targetDiscordId});
}
var allowedMentions = new Dictionary<string, string[]>
{
{ "parse", new List<string> {"users"}.ToArray() }
};
if (banDef.ExpirationTime != null && minutes != null) // Time ban
return new WebhookPayload
{
Username = _webhookName,
AvatarUrl = _webhookAvatarUrl,
AllowedMentions = allowedMentions,
Mentions = mentions,
Embeds = new List<Embed>
{
new()
{
Description = Loc.GetString(
"server-time-ban-string",
("targetName", targetName),
("targetLink", targetLink),
("adminLink", adminLink),
("adminName", adminName),
("TimeNow", timeNow),
("expiresString", expiresString),
("reason", reason),
("severity", Loc.GetString($"admin-note-editor-severity-{severity.ToLower()}"))),
Color = 0x803045,
Thumbnail = new EmbedThumbnail
{
Url = "https://static.wikia.nocookie.net/ss14andromeda13/images/f/ff/Clown.png/revision/latest?cb=20230217121049&path-prefix=ru",
},
Author = new EmbedAuthor
{
Name = Loc.GetString("server-time-ban", ("mins", minutes.Value)) + $" #{id}",
IconUrl = "https://cdn.discordapp.com/emojis/1129749368199712829.webp?size=40&quality=lossless" // Смайлик бан хаммера. URL прямо из дискорд)
},
Footer = new EmbedFooter
{
Text = Loc.GetString("server-ban-footer", ("server", serverName), ("round", round)),
IconUrl = "https://cdn.discordapp.com/emojis/1143995749928030208.webp?size=40&quality=lossless"
},
},
},
};
else // Perma ban
return new WebhookPayload
{
Username = _webhookName,
AvatarUrl = _webhookAvatarUrl,
AllowedMentions = allowedMentions,
Mentions = mentions,
Embeds = new List<Embed>
{
new()
{
Description = Loc.GetString(
"server-perma-ban-string",
("targetName", targetName),
("targetLink", targetLink),
("adminLink", adminLink),
("adminName", adminName),
("TimeNow", timeNow),
("reason", reason),
("severity", Loc.GetString($"admin-note-editor-severity-{severity.ToLower()}"))),
Color = 0x8B0000,
Thumbnail = new EmbedThumbnail
{
Url = "https://static.wikia.nocookie.net/ss14andromeda13/images/7/72/%D0%94%D0%B5%D1%82%D0%B5%D0%BA%D1%82%D0%B8%D0%B2.png/revision/latest?cb=20230216091637&path-prefix=ru",
},
Author = new EmbedAuthor
{
Name = $"{Loc.GetString("server-perma-ban")} #{id}",
IconUrl = "https://cdn.discordapp.com/emojis/1129749368199712829.webp?size=40&quality=lossless" // Смайлик бан хаммера. URL прямо из дискорд)
},
Footer = new EmbedFooter
{
Text = Loc.GetString("server-ban-footer", ("server", serverName), ("round", round)),
IconUrl = "https://cdn.discordapp.com/emojis/1129769076647002122.webp?size=40&quality=lossless"
},
},
},
};
}
private void OnWebhookChanged(string url)
{
_webhookUrl = 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
_sawmill.Warning("Webhook URL does not appear to be valid. Using anyways...");
return;
}
if (match.Groups.Count <= 2)
{
_sawmill.Error("Could not get webhook ID or token.");
return;
}
var webhookId = match.Groups[1].Value;
var webhookToken = match.Groups[2].Value;
// Fire and forget
_ = 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<WebhookData>(content);
}
// 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("author")]
public EmbedAuthor? Author { get; set; } = null;
[JsonPropertyName("thumbnail")]
public EmbedThumbnail? Thumbnail { get; set; } = null;
[JsonPropertyName("footer")]
public EmbedFooter? Footer { get; set; } = null;
public Embed()
{
}
}
// https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure
private struct EmbedAuthor
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("icon_url")]
public string? IconUrl { get; set; }
public EmbedAuthor()
{
}
}
// 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()
{
}
}
// 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<Embed>? Embeds { get; set; } = null;
[JsonPropertyName("mentions")]
public List<User> Mentions { get; set; } = new();
[JsonPropertyName("allowed_mentions")]
public Dictionary<string, string[]> AllowedMentions { get; set; } =
new()
{
{ "parse", Array.Empty<string>() },
};
public WebhookPayload()
{
}
}
// 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/channel#embed-object-embed-footer-structure
private struct EmbedThumbnail
{
[JsonPropertyName("url")]
public string Url { get; set; } = "";
public EmbedThumbnail()
{
}
}
private struct User
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
public User()
{
}
}
#endregion
[UsedImplicitly]
private sealed record DiscordUserResponse(string UserId, string Username);
// Sunrise-end
}