312 lines
13 KiB
C#

using System.IO;
using System.IO.Compression;
using CompatApiClient.Utils;
using CompatBot.EventHandlers;
using DSharpPlus.Commands.Processors.TextCommands;
namespace CompatBot.Commands;
[Command("audit"), RequiresBotModRole, LimitedToSpamChannel]
[Description("Commands to audit server things")]
internal static class Audit
{
public static readonly SemaphoreSlim CheckLock = new(1, 1);
[Command("spoofing"), TextAlias("impersonation"), RequiresDm]
[Description("Checks every user on the server for name spoofing")]
public static ValueTask Spoofing(TextCommandContext ctx)
{
SpoofingCheck(ctx);
return ValueTask.CompletedTask;
}
[Command("members"), TextAlias("users"), RequiresDm]
[Description("Dumps server member information, including usernames, nicknames, and roles")]
public static async ValueTask Members(TextCommandContext ctx)
{
if (!await CheckLock.WaitAsync(0).ConfigureAwait(false))
{
await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false);
return;
}
try
{
await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false);
var members = GetMembers(ctx.Client);
await using var compressedResult = Config.MemoryStreamManager.GetStream();
await using var memoryStream = Config.MemoryStreamManager.GetStream();
await using var writer = new StreamWriter(memoryStream, new UTF8Encoding(false), 4096, true);
foreach (var member in members)
await writer.WriteLineAsync($"{member.Username}\t{member.Nickname}\t{member.JoinedAt:O}\t{string.Join(',', member.Roles.Select(r => r.Name))}").ConfigureAwait(false);
await writer.FlushAsync().ConfigureAwait(false);
memoryStream.Seek(0, SeekOrigin.Begin);
if (memoryStream.Length <= ctx.GetAttachmentSizeLimit())
{
await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().AddFile("names.txt", memoryStream)).ConfigureAwait(false);
return;
}
await using var gzip = new GZipStream(compressedResult, CompressionLevel.Optimal, true);
await memoryStream.CopyToAsync(gzip).ConfigureAwait(false);
await gzip.FlushAsync().ConfigureAwait(false);
compressedResult.Seek(0, SeekOrigin.Begin);
if (compressedResult.Length <= ctx.GetAttachmentSizeLimit())
await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().AddFile("names.txt.gz", compressedResult)).ConfigureAwait(false);
else
await ctx.Channel.SendMessageAsync($"Dump is too large: {compressedResult.Length} bytes").ConfigureAwait(false);
}
catch (Exception e)
{
Config.Log.Warn(e, "Failed to dump guild members");
await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to dump guild members").ConfigureAwait(false);
}
finally
{
CheckLock.Release();
await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false);
}
}
[Command("raid")]
[Description("Kick known raiders")]
public static async ValueTask Raid(TextCommandContext ctx)
{
if (!await CheckLock.WaitAsync(0).ConfigureAwait(false))
{
await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false);
return;
}
try
{
await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false);
var result = new StringBuilder("List of users:").AppendLine();
var headerLength = result.Length;
var members = GetMembers(ctx.Client);
foreach (var member in members)
try
{
var displayName = member.DisplayName;
if (!UsernameRaidMonitor.NeedsKick(displayName))
continue;
try
{
await member.RemoveAsync("Anti Raid").ConfigureAwait(false);
result.AppendLine($"{member.Username} have been automatically kicked");
}
catch (Exception e)
{
Config.Log.Warn(e, $"Failed to kick member {member.GetUsernameWithNickname()}");
}
}
catch (Exception e)
{
Config.Log.Warn(e, $"Failed to audit username for {member.Id}");
}
if (result.Length == headerLength)
result.AppendLine("No naughty users 🎉");
await ctx.SendAutosplitMessageAsync(result, blockStart: "", blockEnd: "").ConfigureAwait(false);
}
catch (Exception e)
{
var msg = "Failed to check display names for raids for all guild members";
Config.Log.Warn(e, msg);
await ctx.ReactWithAsync(Config.Reactions.Failure, msg).ConfigureAwait(false);
}
finally
{
CheckLock.Release();
await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false);
}
}
[Command("zalgo"), TextAlias("diacritics")]
[Description("Checks every member's display name for discord and rule #7 requirements")]
public static async ValueTask Zalgo(TextCommandContext ctx)
{
if (!await CheckLock.WaitAsync(0).ConfigureAwait(false))
{
await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false);
return;
}
try
{
await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false);
var result = new StringBuilder("List of users who do not meet Rule #7 requirements:").AppendLine();
var headerLength = result.Length;
var members = GetMembers(ctx.Client);
foreach (var member in members)
try
{
var displayName = member.DisplayName;
if (!await UsernameZalgoMonitor.NeedsRenameAsync(displayName).ConfigureAwait(false))
continue;
var nickname = await UsernameZalgoMonitor.StripZalgoAsync(displayName, member.Username, member.Id).ConfigureAwait(false);
nickname = nickname.Sanitize();
try
{
await member.ModifyAsync(m => m.Nickname = nickname).ConfigureAwait(false);
result.AppendLine($"{member.Mention} have been automatically renamed from {displayName} to {nickname} according Rule #7");
}
catch (Exception e)
{
Config.Log.Warn(e, $"Failed to rename member {member.GetUsernameWithNickname()}");
result.AppendLine($"{member.Mention} please change your nickname according to Rule #7 (suggestion: {nickname})");
}
}
catch (Exception e)
{
Config.Log.Warn(e, $"Failed to audit username for {member.Id}");
}
if (result.Length == headerLength)
result.AppendLine("No naughty users 🎉");
await ctx.SendAutosplitMessageAsync(result, blockStart: "", blockEnd: "").ConfigureAwait(false);
}
catch (Exception e)
{
var msg = "Failed to check display names for zalgo for all guild members";
Config.Log.Warn(e, msg);
await ctx.ReactWithAsync(Config.Reactions.Failure, msg).ConfigureAwait(false);
}
finally
{
CheckLock.Release();
await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false);
}
}
#if DEBUG
[Command("locales"), TextAlias("locale", "languages", "language", "lang", "loc")]
public static async ValueTask UserLocales(TextCommandContext ctx)
{
#pragma warning disable VSTHRD103
if (!CheckLock.Wait(0))
#pragma warning restore VSTHRD103
{
await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false);
return;
}
try
{
await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false);
var members = GetMembers(ctx.Client);
var stats = new Dictionary<string, int>();
foreach (var m in members)
{
var loc = m.Locale ?? "Unknown";
if (stats.ContainsKey(loc))
stats[loc]++;
else
stats[loc] = 1;
}
var table = new AsciiTable(
new AsciiColumn("Locale"),
new AsciiColumn("Count", alignToRight: true),
new AsciiColumn("%", alignToRight: true)
);
var total = stats.Values.Sum();
foreach (var lang in stats.OrderByDescending(l => l.Value).ThenBy(l => l.Key))
table.Add(lang.Key, lang.Value.ToString(), $"{100.0 * lang.Value / total:0.00}%");
await ctx.SendAutosplitMessageAsync(new StringBuilder().AppendLine("Member locale stats:").Append(table)).ConfigureAwait(false);
}
catch (Exception e)
{
Config.Log.Warn(e, "Failed to get locale stats");
await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to get locale stats").ConfigureAwait(false);
}
finally
{
CheckLock.Release();
await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false);
}
}
#endif
private static List<DiscordMember> GetMembers(DiscordClient client)
{
//owner -> white name
//newbs -> veterans
return client.Guilds.Select(g => g.Value.GetAllMembersAsync())
.SelectMany(l => l.ToList())
.OrderByDescending(m => m.Hierarchy)
.ThenByDescending(m => m.JoinedAt)
.ToList();
}
private static async void SpoofingCheck(TextCommandContext ctx)
{
if (!CheckLock.Wait(0))
{
await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false);
return;
}
try
{
await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false);
var members = GetMembers(ctx.Client);
if (members.Count < 2)
return;
var result = new StringBuilder("List of potential impersonators → victims:").AppendLine();
var headerLength = result.Length;
var checkedMembers = new List<DiscordMember>(members.Count) {members[0]};
for (var i = 1; i < members.Count; i++)
{
var member = members[i];
var victims = UsernameSpoofMonitor.GetPotentialVictims(ctx.Client, member, true, true, checkedMembers);
if (victims.Any())
result.Append(member.GetMentionWithNickname()).Append(" → ").AppendLine(string.Join(", ", victims.Select(m => m.GetMentionWithNickname())));
checkedMembers.Add(member);
}
await using var compressedStream = Config.MemoryStreamManager.GetStream();
await using var uncompressedStream = Config.MemoryStreamManager.GetStream();
await using (var writer = new StreamWriter(uncompressedStream, new UTF8Encoding(false), 4096, true))
{
await writer.WriteAsync(result.ToString()).ConfigureAwait(false);
await writer.FlushAsync().ConfigureAwait(false);
}
uncompressedStream.Seek(0, SeekOrigin.Begin);
if (result.Length <= headerLength)
{
await ctx.Channel.SendMessageAsync("No potential name spoofing was detected").ConfigureAwait(false);
return;
}
if (uncompressedStream.Length <= ctx.GetAttachmentSizeLimit())
{
await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().AddFile("spoofing_check_results.txt", uncompressedStream)).ConfigureAwait(false);
return;
}
await using (var gzip = new GZipStream(compressedStream, CompressionLevel.Optimal, true))
{
await uncompressedStream.CopyToAsync(gzip).ConfigureAwait(false);
gzip.Flush();
}
compressedStream.Seek(0, SeekOrigin.Begin);
if (compressedStream.Length <= ctx.GetAttachmentSizeLimit())
await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().AddFile("spoofing_check_results.txt.gz", compressedStream)).ConfigureAwait(false);
else
await ctx.Channel.SendMessageAsync($"Dump is too large: {compressedStream.Length} bytes").ConfigureAwait(false);
}
catch (Exception e)
{
Config.Log.Error(e);
//should be extra careful, as async void will run on a thread pull, and will terminate the whole application with an uncaught exception
try { await ctx.ReactWithAsync(Config.Reactions.Failure, "(X_X)").ConfigureAwait(false); } catch { }
}
finally
{
CheckLock.Release();
await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false);
}
}
}