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>()
});
}

View file

@ -83,10 +83,12 @@ internal partial class Database: IDatabase
SqlMapper.AddTypeHandler(new NumericIdHandler<MemberId, int>(i => new MemberId(i)));
SqlMapper.AddTypeHandler(new NumericIdHandler<SwitchId, int>(i => new SwitchId(i)));
SqlMapper.AddTypeHandler(new NumericIdHandler<GroupId, int>(i => new GroupId(i)));
SqlMapper.AddTypeHandler(new NumericIdHandler<AbuseLogId, int>(i => new AbuseLogId(i)));
SqlMapper.AddTypeHandler(new NumericIdArrayHandler<SystemId, int>(i => new SystemId(i)));
SqlMapper.AddTypeHandler(new NumericIdArrayHandler<MemberId, int>(i => new MemberId(i)));
SqlMapper.AddTypeHandler(new NumericIdArrayHandler<SwitchId, int>(i => new SwitchId(i)));
SqlMapper.AddTypeHandler(new NumericIdArrayHandler<GroupId, int>(i => new GroupId(i)));
SqlMapper.AddTypeHandler(new NumericIdArrayHandler<AbuseLogId, int>(i => new AbuseLogId(i)));
// Register our custom types to Npgsql
// Without these it'll still *work* but break at the first launch + probably cause other small issues

View file

@ -31,4 +31,5 @@ public class MessageContext
public int? LatchTimeout { get; }
public bool CaseSensitiveProxyTags { get; }
public bool ProxyErrorMessageEnabled { get; }
public bool DenyBotUsage { get; }
}

View file

@ -17,7 +17,8 @@
allow_autoproxy bool,
latch_timeout integer,
case_sensitive_proxy_tags bool,
proxy_error_message_enabled bool
proxy_error_message_enabled bool,
deny_bot_usage bool
)
as $$
-- CTEs to query "static" (accessible only through args) data
@ -28,6 +29,7 @@ as $$
left join systems on systems.id = accounts.system
left join system_config on system_config.system = accounts.system
left join system_guild on system_guild.system = accounts.system and system_guild.guild = guild_id
left join abuse_logs on abuse_logs.id = accounts.abuse_log
where accounts.uid = account_id),
guild as (select * from servers where id = guild_id)
select
@ -50,14 +52,17 @@ as $$
system.account_autoproxy as allow_autoproxy,
system.latch_timeout as latch_timeout,
system.case_sensitive_proxy_tags as case_sensitive_proxy_tags,
system.proxy_error_message_enabled as proxy_error_message_enabled
system.proxy_error_message_enabled as proxy_error_message_enabled,
coalesce(abuse_logs.deny_bot_usage, false) as deny_bot_usage
-- We need a "from" clause, so we just use some bogus data that's always present
-- This ensure we always have exactly one row going forward, so we can left join afterwards and still get data
from (select 1) as _placeholder
left join system on true
left join guild on true
left join accounts on true
left join system_last_switch on system_last_switch.system = system.id
left join system_guild on system_guild.system = system.id and system_guild.guild = guild_id
left join abuse_logs on abuse_logs.id = accounts.abuse_log
$$ language sql stable rows 1;

View file

@ -0,0 +1,23 @@
-- database version 44
-- add abuse handling measures
create table abuse_logs (
id serial primary key,
uuid uuid default gen_random_uuid(),
description text,
deny_bot_usage bool not null default false,
created timestamp not null default (current_timestamp at time zone 'utc')
);
alter table accounts add column abuse_log integer default null references abuse_logs (id) on delete set null;
create index abuse_logs_uuid_idx on abuse_logs (uuid);
-- we now need to handle a row in "accounts" table being created with no
-- system (rather than just system being set to null after insert)
--
-- set default null and drop the sequence (from the column being created
-- as type SERIAL)
alter table accounts alter column system set default null;
drop sequence accounts_system_seq;
update info set schema_version = 44;

View file

@ -0,0 +1,70 @@
using Dapper;
using SqlKata;
namespace PluralKit.Core;
public partial class ModelRepository
{
public Task<AbuseLog?> GetAbuseLogByGuid(Guid id)
{
var query = new Query("abuse_logs").Where("uuid", id);
return _db.QueryFirst<AbuseLog?>(query);
}
public Task<AbuseLog?> GetAbuseLogByAccount(ulong accountId)
{
var query = new Query("accounts")
.Select("abuse_logs.*")
.LeftJoin("abuse_logs", "abuse_logs.id", "accounts.abuse_log")
.Where("uid", accountId)
.WhereNotNull("abuse_log");
return _db.QueryFirst<AbuseLog?>(query);
}
public Task<IEnumerable<ulong>> GetAbuseLogAccounts(AbuseLogId id)
{
var query = new Query("accounts").Select("uid").Where("abuse_log", id);
return _db.Query<ulong>(query);
}
public async Task<AbuseLog> CreateAbuseLog(string? desc = null, bool? denyBotUsage = null, IPKConnection? conn = null)
{
var query = new Query("abuse_logs").AsInsert(new
{
description = desc,
deny_bot_usage = denyBotUsage,
});
var abuseLog = await _db.QueryFirst<AbuseLog>(conn, query, "returning *");
_logger.Information("Created {AbuseLogId}", abuseLog.Id);
return abuseLog;
}
public async Task AddAbuseLogAccount(AbuseLogId abuseLog, ulong accountId, IPKConnection? conn = null)
{
var query = new Query("accounts").AsInsert(new
{
abuse_log = abuseLog,
uid = accountId,
});
await _db.ExecuteQuery(conn, query, "on conflict (uid) do update set abuse_log = @p0");
_logger.Information("Linked account {UserId} to {AbuseLogId}", accountId, abuseLog);
}
public async Task<AbuseLog> UpdateAbuseLog(AbuseLogId id, AbuseLogPatch patch, IPKConnection? conn = null)
{
_logger.Information("Updated {AbuseLogId}: {@AbuseLogPatch}", id, patch);
var query = patch.Apply(new Query("abuse_logs").Where("id", id));
return await _db.QueryFirst<AbuseLog>(conn, query, "returning *");
}
public async Task DeleteAbuseLog(AbuseLogId id)
{
var query = new Query("abuse_logs").AsDelete().Where("id", id);
await _db.ExecuteQuery(query);
_logger.Information("Deleted {AbuseLogId}", id);
}
}

View file

@ -9,7 +9,7 @@ namespace PluralKit.Core;
internal class DatabaseMigrator
{
private const string RootPath = "PluralKit.Core.Database"; // "resource path" root for SQL files
private const int TargetSchemaVersion = 43;
private const int TargetSchemaVersion = 44;
private readonly ILogger _logger;
public DatabaseMigrator(ILogger logger)

View file

@ -0,0 +1,36 @@
using NodaTime;
namespace PluralKit.Core;
public readonly struct AbuseLogId: INumericId<AbuseLogId, int>
{
public int Value { get; }
public AbuseLogId(int value)
{
Value = value;
}
public bool Equals(AbuseLogId other) => Value == other.Value;
public override bool Equals(object obj) => obj is AbuseLogId other && Equals(other);
public override int GetHashCode() => Value;
public static bool operator ==(AbuseLogId left, AbuseLogId right) => left.Equals(right);
public static bool operator !=(AbuseLogId left, AbuseLogId right) => !left.Equals(right);
public int CompareTo(AbuseLogId other) => Value.CompareTo(other.Value);
public override string ToString() => $"AbuseLog #{Value}";
}
public class AbuseLog
{
public AbuseLogId Id { get; private set; }
public Guid Uuid { get; private set; }
public string Description { get; private set; }
public bool DenyBotUsage { get; private set; }
public Instant Created { get; private set; }
}

View file

@ -0,0 +1,16 @@
using Newtonsoft.Json.Linq;
using SqlKata;
namespace PluralKit.Core;
public class AbuseLogPatch: PatchObject
{
public Partial<string> Description { get; set; }
public Partial<bool> DenyBotUsage { get; set; }
public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper
.With("description", Description)
.With("deny_bot_usage", DenyBotUsage)
);
}

View file

@ -8,10 +8,12 @@ public class AccountPatch: PatchObject
{
public Partial<ulong> DmChannel { get; set; }
public Partial<bool> AllowAutoproxy { get; set; }
public Partial<AbuseLogId?> AbuseLog { get; set; }
public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper
.With("dm_channel", DmChannel)
.With("allow_autoproxy", AllowAutoproxy)
.With("abuse_log", AbuseLog)
);
public JObject ToJson()