feat: add abuse handling

This commit is contained in:
Iris System 2024-10-23 10:08:25 +13:00
parent 4bf60a47d7
commit 2dfb851246
17 changed files with 405 additions and 16 deletions

View file

@ -101,6 +101,14 @@ public class ApplicationCommandProxiedMessage
public async Task PingMessageAuthor(InteractionContext ctx)
{
// if the command message was sent by a user account with bot usage disallowed, ignore it
var abuse_log = await _repo.GetAbuseLogByAccount(ctx.User.Id);
if (abuse_log != null && abuse_log.DenyBotUsage)
{
await ctx.Defer();
return;
}
var messageId = ctx.Event.Data!.TargetId!.Value;
var msg = await ctx.Repository.GetFullMessage(messageId);
if (msg == null)

View file

@ -113,6 +113,48 @@ public partial class CommandTree
$"{Emojis.Error} Unknown command {ctx.PeekArgument().AsCode()}. For a list of possible commands, see <https://pluralkit.me/commands>.");
}
private async Task HandleAdminAbuseLogCommand(Context ctx)
{
ctx.AssertBotAdmin();
if (ctx.Match("n", "new", "create"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogCreate(ctx));
else
{
AbuseLog? abuseLog = null!;
var account = await ctx.MatchUser();
if (account != null)
{
abuseLog = await ctx.Repository.GetAbuseLogByAccount(account.Id);
}
else
{
abuseLog = await ctx.Repository.GetAbuseLogByGuid(new Guid(ctx.PopArgument()));
}
if (abuseLog == null)
{
await ctx.Reply($"{Emojis.Error} Could not find an existing abuse log entry for that query.");
return;
}
if (!ctx.HasNext())
await ctx.Execute<Admin>(Admin, a => a.AbuseLogShow(ctx, abuseLog));
else if (ctx.Match("au", "adduser"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogAddUser(ctx, abuseLog));
else if (ctx.Match("ru", "removeuser"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogRemoveUser(ctx, abuseLog));
else if (ctx.Match("desc", "description"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogDescription(ctx, abuseLog));
else if (ctx.Match("deny", "deny-bot-usage"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogFlagDeny(ctx, abuseLog));
else if (ctx.Match("yeet", "remove", "delete"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogDelete(ctx, abuseLog));
else
await ctx.Reply($"{Emojis.Error} Unknown subcommand {ctx.PeekArgument().AsCode()}.");
}
}
private async Task HandleAdminCommand(Context ctx)
{
if (ctx.Match("usid", "updatesystemid"))
@ -133,6 +175,10 @@ public partial class CommandTree
await ctx.Execute<Admin>(Admin, a => a.SystemGroupLimit(ctx));
else if (ctx.Match("sr", "systemrecover"))
await ctx.Execute<Admin>(Admin, a => a.SystemRecover(ctx));
else if (ctx.Match("sd", "systemdelete"))
await ctx.Execute<Admin>(Admin, a => a.SystemDelete(ctx));
else if (ctx.Match("al", "abuselog"))
await HandleAdminAbuseLogCommand(ctx);
else
await ctx.Reply($"{Emojis.Error} Unknown command.");
}

View file

@ -14,7 +14,11 @@ public static class ContextEntityArgumentsExt
{
var text = ctx.PeekArgument();
if (text.TryParseMention(out var id))
return await ctx.Cache.GetOrFetchUser(ctx.Rest, id);
{
var user = await ctx.Cache.GetOrFetchUser(ctx.Rest, id);
if (user != null) ctx.PopArgument();
return user;
}
return null;
}

View file

@ -27,6 +27,17 @@ public class Admin
_cache = cache;
}
private Task<(ulong Id, User? User)[]> GetUsers(IEnumerable<ulong> ids)
{
async Task<(ulong Id, User? User)> Inner(ulong id)
{
var user = await _cache.GetOrFetchUser(_rest, id);
return (id, user);
}
return Task.WhenAll(ids.Select(Inner));
}
public async Task<Embed> CreateEmbed(Context ctx, PKSystem system)
{
string UntilLimit(int count, int limit)
@ -44,17 +55,6 @@ public class Admin
return "";
}
Task<(ulong Id, User? User)[]> GetUsers(IEnumerable<ulong> ids)
{
async Task<(ulong Id, User? User)> Inner(ulong id)
{
var user = await _cache.GetOrFetchUser(_rest, id);
return (id, user);
}
return Task.WhenAll(ids.Select(Inner));
}
var config = await ctx.Repository.GetSystemConfig(system.Id);
// Fetch/render info for all accounts simultaneously
@ -78,6 +78,37 @@ public class Admin
return eb.Build();
}
public async Task<Embed> CreateAbuseLogEmbed(Context ctx, AbuseLog abuseLog)
{
// Fetch/render info for all accounts simultaneously
var accounts = await ctx.Repository.GetAbuseLogAccounts(abuseLog.Id);
var systems = await Task.WhenAll(accounts.Select(x => ctx.Repository.GetSystemByAccount(x)));
var users = (await GetUsers(accounts)).Select(x => x.User?.NameAndMention() ?? $"(deleted: `{x.Id}`)");
List<string> flagstr = new();
if (abuseLog.DenyBotUsage)
flagstr.Add("- bot usage denied");
var eb = new EmbedBuilder()
.Title($"Abuse log: {abuseLog.Uuid.ToString()}")
.Color(DiscordUtils.Red)
.Footer(new Embed.EmbedFooter($"Created on {abuseLog.Created.FormatZoned(ctx.Zone)}"));
if (systems.Any(x => x != null))
{
var sysList = string.Join(", ", systems.Select(x => $"`{x.DisplayHid()}`"));
eb.Field(new Embed.Field($"{Emojis.Warn} Accounts have registered system(s)", sysList));
}
eb.Field(new Embed.Field("Accounts", string.Join("\n", users).Truncate(1000), true));
eb.Field(new Embed.Field("Flags", flagstr.Any() ? string.Join("\n", flagstr) : "(none)", true));
if (abuseLog.Description != null)
eb.Field(new Embed.Field("Description", abuseLog.Description.Truncate(1000)));
return eb.Build();
}
public async Task UpdateSystemId(Context ctx)
{
ctx.AssertBotAdmin();
@ -342,4 +373,127 @@ public class Admin
Color = DiscordUtils.Green,
});
}
public async Task SystemDelete(Context ctx)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchSystem();
if (target == null)
throw new PKError("Unknown system.");
await ctx.Reply($"To delete the following system, reply with the system's UUID: `{target.Uuid.ToString()}`",
await CreateEmbed(ctx, target));
if (!await ctx.ConfirmWithReply(target.Uuid.ToString()))
throw new PKError("System deletion cancelled.");
await ctx.BusyIndicator(async () =>
await ctx.Repository.DeleteSystem(target.Id));
await ctx.Reply($"{Emojis.Success} System deletion succesful.");
}
public async Task AbuseLogCreate(Context ctx)
{
var denyBotUsage = ctx.MatchFlag("deny", "deny-bot-usage");
var account = await ctx.MatchUser();
if (account == null)
throw new PKError("You must pass an account to associate the abuse log with (either ID or @mention).");
string? desc = null!;
if (ctx.HasNext(false))
desc = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
var abuseLog = await ctx.Repository.CreateAbuseLog(desc, denyBotUsage);
await ctx.Repository.AddAbuseLogAccount(abuseLog.Id, account.Id);
await ctx.Reply(
$"Created new abuse log with UUID `{abuseLog.Uuid.ToString()}`.",
await CreateAbuseLogEmbed(ctx, abuseLog));
}
public async Task AbuseLogShow(Context ctx, AbuseLog abuseLog)
{
await ctx.Reply(null, await CreateAbuseLogEmbed(ctx, abuseLog));
}
public async Task AbuseLogFlagDeny(Context ctx, AbuseLog abuseLog)
{
if (!ctx.HasNext())
{
await ctx.Reply(
$"Bot usage is currently {(abuseLog.DenyBotUsage ? "denied" : "allowed")} "
+ $"for accounts associated with abuse log `{abuseLog.Uuid}`.");
}
else
{
var value = ctx.MatchToggle(true);
if (abuseLog.DenyBotUsage != value)
await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { DenyBotUsage = value });
await ctx.Reply(
$"Bot usage is now **{(value ? "denied" : "allowed")}** "
+ $"for accounts associated with abuse log `{abuseLog.Uuid}`.");
}
}
public async Task AbuseLogDescription(Context ctx, AbuseLog abuseLog)
{
if (ctx.MatchClear() && await ctx.ConfirmClear("this abuse log description"))
{
await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { Description = null });
await ctx.Reply($"{Emojis.Success} Abuse log description cleared.");
}
else if (ctx.HasNext())
{
var desc = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { Description = desc });
await ctx.Reply($"{Emojis.Success} Abuse log description updated.");
}
else
{
var eb = new EmbedBuilder()
.Description($"Showing description for abuse log `{abuseLog.Uuid}`");
await ctx.Reply(abuseLog.Description, eb.Build());
}
}
public async Task AbuseLogAddUser(Context ctx, AbuseLog abuseLog)
{
var account = await ctx.MatchUser();
if (account == null)
throw new PKError("You must pass an account to associate the abuse log with (either ID or @mention).");
await ctx.Repository.AddAbuseLogAccount(abuseLog.Id, account.Id);
await ctx.Reply(
$"Added user {account.NameAndMention()} to the abuse log with UUID `{abuseLog.Uuid.ToString()}`.",
await CreateAbuseLogEmbed(ctx, abuseLog));
}
public async Task AbuseLogRemoveUser(Context ctx, AbuseLog abuseLog)
{
var account = await ctx.MatchUser();
if (account == null)
throw new PKError("You must pass an account to remove from the abuse log (either ID or @mention).");
await ctx.Repository.UpdateAccount(account.Id, new()
{
AbuseLog = null,
});
await ctx.Reply(
$"Removed user {account.NameAndMention()} from the abuse log with UUID `{abuseLog.Uuid.ToString()}`.",
await CreateAbuseLogEmbed(ctx, abuseLog));
}
public async Task AbuseLogDelete(Context ctx, AbuseLog abuseLog)
{
if (!await ctx.PromptYesNo($"Really delete abuse log entry `{abuseLog.Uuid}`?", "Delete", matchFlag: false))
{
await ctx.Reply($"{Emojis.Error} Deletion cancelled.");
return;
}
await ctx.Repository.DeleteAbuseLog(abuseLog.Id);
await ctx.Reply($"{Emojis.Success} Successfully deleted abuse log entry.");
}
}

View file

@ -114,6 +114,11 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
if (!HasCommandPrefix(content, _config.ClientId, out var cmdStart) || cmdStart == content.Length)
return false;
// if the command message was sent by a user account with bot usage disallowed, ignore it
var abuse_log = await _repo.GetAbuseLogByAccount(evt.Author.Id);
if (abuse_log != null && abuse_log.DenyBotUsage)
return false;
// Trim leading whitespace from command without actually modifying the string
// This just moves the argPos pointer by however much whitespace is at the start of the post-argPos string
var trimStartLengthDiff =
@ -162,6 +167,9 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime))
ctx = await _repo.GetMessageContext(evt.Author.Id, evt.GuildId ?? default, rootChannel, channel.Id != rootChannel ? channel.Id : default);
if (ctx.DenyBotUsage)
return false;
try
{
return await _proxy.HandleIncomingMessage(evt, ctx, guild, channel, true, botPermissions);

View file

@ -69,6 +69,8 @@ public class MessageEdited: IEventHandler<MessageUpdateEvent>
MessageContext ctx;
using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime))
ctx = await _repo.GetMessageContext(evt.Author.Value!.Id, channel.GuildId!.Value, rootChannel.Id, evt.ChannelId);
if (ctx.DenyBotUsage)
return;
var equivalentEvt = await GetMessageCreateEvent(evt, lastMessage, channel);
var botPermissions = await _cache.BotPermissionsIn(guildIdMaybe, channel.Id);

View file

@ -85,6 +85,7 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
// Proxied messages only exist in guild text channels, so skip checking if we're elsewhere
if (!DiscordUtils.IsValidGuildChannel(channel)) return;
var abuse_log = await _repo.GetAbuseLogByAccount(evt.Member!.User!.Id);
switch (evt.Emoji.Name.Split("\U0000fe0f", 2)[0])
{
@ -113,6 +114,7 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
case "\u23F0": // Alarm clock
case "\u2757": // Exclamation mark
{
if (abuse_log != null && abuse_log.DenyBotUsage) break;
var msg = await _repo.GetFullMessage(evt.MessageId);
if (msg != null)
await HandlePingReaction(evt, msg);

View file

@ -76,12 +76,22 @@ public class InteractionContext
});
}
public async Task Defer()
{
await Respond(InteractionResponse.ResponseType.DeferredChannelMessageWithSource,
new InteractionApplicationCommandCallbackData
{
Components = Array.Empty<MessageComponent>(),
Flags = Message.MessageFlags.Ephemeral,
});
}
public async Task Ignore()
{
await Respond(InteractionResponse.ResponseType.DeferredUpdateMessage,
new InteractionApplicationCommandCallbackData
{
Components = Event.Message.Components
Components = Event.Message?.Components ?? Array.Empty<MessageComponent>()
});
}