PluralKit/PluralKit.Bot/Commands/Admin.cs
2025-09-09 10:43:00 -04:00

529 lines
No EOL
20 KiB
C#

using System.Text.RegularExpressions;
using Humanizer;
using Dapper;
using SqlKata;
using Myriad.Builders;
using Myriad.Extensions;
using Myriad.Cache;
using Myriad.Rest;
using Myriad.Types;
using Myriad.Rest.Types.Requests;
using PluralKit.Core;
namespace PluralKit.Bot;
public class Admin
{
private readonly BotConfig _botConfig;
private readonly DiscordApiClient _rest;
private readonly IDiscordCache _cache;
public Admin(BotConfig botConfig, DiscordApiClient rest, IDiscordCache cache)
{
_botConfig = botConfig;
_rest = rest;
_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)
{
var brackets = new List<int> { 10, 25, 50, 100 };
if (count == limit)
return "(at limit)";
foreach (var x in brackets)
{
if (limit - x <= count)
return $"(approx. {x} to limit)";
}
return "";
}
var config = await ctx.Repository.GetSystemConfig(system.Id);
// Fetch/render info for all accounts simultaneously
var accounts = await ctx.Repository.GetSystemAccounts(system.Id);
var users = (await GetUsers(accounts)).Select(x => x.User?.NameAndMention() ?? $"(deleted: `{x.Id}`)");
var eb = new EmbedBuilder()
.Title("System info")
.Color(DiscordUtils.Green)
.Field(new Embed.Field("System ID", $"`{system.Hid}`"))
.Field(new Embed.Field("Linked accounts", string.Join("\n", users).Truncate(1000)));
var memberLimit = config.MemberLimitOverride ?? Limits.MaxMemberCount;
var memberCount = await ctx.Repository.GetSystemMemberCount(system.Id);
eb.Field(new Embed.Field("Member limit", $"{memberLimit} {UntilLimit(memberCount, memberLimit)}", true));
var groupLimit = config.GroupLimitOverride ?? Limits.MaxGroupCount;
var groupCount = await ctx.Repository.GetSystemGroupCount(system.Id);
eb.Field(new Embed.Field("Group limit", $"{groupLimit} {UntilLimit(groupCount, groupLimit)}", true));
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();
var target = await ctx.MatchSystem();
if (target == null)
throw new PKError("Unknown system.");
var input = ctx.PopArgument();
if (!input.TryParseHid(out var newHid))
throw new PKError($"Invalid new system ID `{input}`.");
var existingSystem = await ctx.Repository.GetSystemByHid(newHid);
if (existingSystem != null)
throw new PKError($"Another system already exists with ID `{newHid}`.");
await ctx.Reply(null, await CreateEmbed(ctx, target));
if (!await ctx.PromptYesNo($"Change system ID of `{target.Hid}` to `{newHid}`?", "Change"))
throw new PKError("ID change cancelled.");
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Hid = newHid });
await ctx.Reply($"{Emojis.Success} System ID updated (`{target.Hid}` -> `{newHid}`).");
}
public async Task UpdateMemberId(Context ctx)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchMember();
if (target == null)
throw new PKError("Unknown member.");
var input = ctx.PopArgument();
if (!input.TryParseHid(out var newHid))
throw new PKError($"Invalid new member ID `{input}`.");
var existingMember = await ctx.Repository.GetMemberByHid(newHid);
if (existingMember != null)
throw new PKError($"Another member already exists with ID `{newHid}`.");
var system = await ctx.Repository.GetSystem(target.System);
await ctx.Reply(null, await CreateEmbed(ctx, system));
if (!await ctx.PromptYesNo(
$"Change member ID of **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`) to `{newHid}`?",
"Change"
))
throw new PKError("ID change cancelled.");
await ctx.Repository.UpdateMember(target.Id, new MemberPatch { Hid = newHid });
await ctx.Reply($"{Emojis.Success} Member ID updated (`{target.Hid}` -> `{newHid}`).");
}
public async Task UpdateGroupId(Context ctx)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchGroup();
if (target == null)
throw new PKError("Unknown group.");
var input = ctx.PopArgument();
if (!input.TryParseHid(out var newHid))
throw new PKError($"Invalid new group ID `{input}`.");
var existingGroup = await ctx.Repository.GetGroupByHid(newHid);
if (existingGroup != null)
throw new PKError($"Another group already exists with ID `{newHid}`.");
var system = await ctx.Repository.GetSystem(target.System);
await ctx.Reply(null, await CreateEmbed(ctx, system));
if (!await ctx.PromptYesNo($"Change group ID of **{target.Name}** (`{target.Hid}`) to `{newHid}`?",
"Change"
))
throw new PKError("ID change cancelled.");
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Hid = newHid });
await ctx.Reply($"{Emojis.Success} Group ID updated (`{target.Hid}` -> `{newHid}`).");
}
public async Task RerollSystemId(Context ctx)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchSystem();
if (target == null)
throw new PKError("Unknown system.");
await ctx.Reply(null, await CreateEmbed(ctx, target));
if (!await ctx.PromptYesNo($"Reroll system ID `{target.Hid}`?", "Reroll"))
throw new PKError("ID change cancelled.");
var query = new Query("systems").AsUpdate(new
{
hid = new UnsafeLiteral("find_free_system_hid()"),
})
.Where("id", target.Id);
var newHid = await ctx.Database.QueryFirst<string>(query, "returning hid");
await ctx.Reply($"{Emojis.Success} System ID updated (`{target.Hid}` -> `{newHid}`).");
}
public async Task RerollMemberId(Context ctx)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchMember();
if (target == null)
throw new PKError("Unknown member.");
var system = await ctx.Repository.GetSystem(target.System);
await ctx.Reply(null, await CreateEmbed(ctx, system));
if (!await ctx.PromptYesNo(
$"Reroll member ID for **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`)?",
"Reroll"
))
throw new PKError("ID change cancelled.");
var query = new Query("members").AsUpdate(new
{
hid = new UnsafeLiteral("find_free_member_hid()"),
})
.Where("id", target.Id);
var newHid = await ctx.Database.QueryFirst<string>(query, "returning hid");
await ctx.Reply($"{Emojis.Success} Member ID updated (`{target.Hid}` -> `{newHid}`).");
}
public async Task RerollGroupId(Context ctx)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchGroup();
if (target == null)
throw new PKError("Unknown group.");
var system = await ctx.Repository.GetSystem(target.System);
await ctx.Reply(null, await CreateEmbed(ctx, system));
if (!await ctx.PromptYesNo($"Reroll group ID for **{target.Name}** (`{target.Hid}`)?",
"Change"
))
throw new PKError("ID change cancelled.");
var query = new Query("groups").AsUpdate(new
{
hid = new UnsafeLiteral("find_free_group_hid()"),
})
.Where("id", target.Id);
var newHid = await ctx.Database.QueryFirst<string>(query, "returning hid");
await ctx.Reply($"{Emojis.Success} Group ID updated (`{target.Hid}` -> `{newHid}`).");
}
public async Task SystemMemberLimit(Context ctx)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchSystem();
if (target == null)
throw new PKError("Unknown system.");
var config = await ctx.Repository.GetSystemConfig(target.Id);
var currentLimit = config.MemberLimitOverride ?? Limits.MaxMemberCount;
if (!ctx.HasNext())
{
await ctx.Reply(null, await CreateEmbed(ctx, target));
return;
}
var newLimitStr = ctx.PopArgument().ToLower().Replace(",", null).Replace("k", "000");
if (!int.TryParse(newLimitStr, out var newLimit))
throw new PKError($"Couldn't parse `{newLimitStr}` as number.");
await ctx.Reply(null, await CreateEmbed(ctx, target));
if (!await ctx.PromptYesNo($"Update member limit from **{currentLimit}** to **{newLimit}**?", "Update"))
throw new PKError("Member limit change cancelled.");
await ctx.Repository.UpdateSystemConfig(target.Id, new SystemConfigPatch { MemberLimitOverride = newLimit });
await ctx.Reply($"{Emojis.Success} Member limit updated.");
}
public async Task SystemGroupLimit(Context ctx)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchSystem();
if (target == null)
throw new PKError("Unknown system.");
var config = await ctx.Repository.GetSystemConfig(target.Id);
var currentLimit = config.GroupLimitOverride ?? Limits.MaxGroupCount;
if (!ctx.HasNext())
{
await ctx.Reply(null, await CreateEmbed(ctx, target));
return;
}
var newLimitStr = ctx.PopArgument().ToLower().Replace(",", null).Replace("k", "000");
if (!int.TryParse(newLimitStr, out var newLimit))
throw new PKError($"Couldn't parse `{newLimitStr}` as number.");
await ctx.Reply(null, await CreateEmbed(ctx, target));
if (!await ctx.PromptYesNo($"Update group limit from **{currentLimit}** to **{newLimit}**?", "Update"))
throw new PKError("Group limit change cancelled.");
await ctx.Repository.UpdateSystemConfig(target.Id, new SystemConfigPatch { GroupLimitOverride = newLimit });
await ctx.Reply($"{Emojis.Success} Group limit updated.");
}
public async Task SystemRecover(Context ctx)
{
ctx.AssertBotAdmin();
var rerollToken = ctx.MatchFlag("rt", "reroll-token");
var systemToken = ctx.PopArgument();
var systemId = await ctx.Database.Execute(conn => conn.QuerySingleOrDefaultAsync<SystemId?>(
"select id from systems where token = @token",
new { token = systemToken }
));
if (systemId == null)
throw new PKError("Could not retrieve a system with that token.");
var account = await ctx.MatchUser();
if (account == null)
throw new PKError("You must pass an account to associate the system with (either ID or @mention).");
var existingAccount = await ctx.Repository.GetSystemByAccount(account.Id);
if (existingAccount != null)
throw Errors.AccountInOtherSystem(existingAccount, ctx.Config, ctx.DefaultPrefix);
var system = await ctx.Repository.GetSystem(systemId.Value!);
await ctx.Reply(null, await CreateEmbed(ctx, system));
if (!await ctx.PromptYesNo($"Associate account {account.NameAndMention()} with system `{system.Hid}`?", "Recover account"))
throw new PKError("System recovery cancelled.");
await ctx.Repository.AddAccount(system.Id, account.Id);
if (rerollToken)
await ctx.Repository.UpdateSystem(system.Id, new SystemPatch { Token = StringUtils.GenerateToken() });
if ((await ctx.BotPermissions).HasFlag(PermissionSet.ManageMessages))
await _rest.DeleteMessage(ctx.Message);
await ctx.Reply(null, new Embed
{
Title = "System recovered",
Description = $"{account.NameAndMention()} has been linked to system `{system.Hid}`.",
Fields = new Embed.Field[]
{
new Embed.Field("Token rerolled?", rerollToken ? "yes" : "no", true),
new Embed.Field("Actioned by", ctx.Author.NameAndMention(), true),
},
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.");
}
public async Task SendAdminMessage(Context ctx)
{
ctx.AssertBotAdmin();
var account = await ctx.MatchUser();
if (account == null)
throw new PKError("You must pass an account to send an admin message to (either ID or @mention).");
if (!ctx.HasNext())
throw new PKError("You must provide a message to send.");
var content = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
var messageContent = $"## [Admin Message]\n\n{content}\n\nWe cannot read replies sent to this DM. If you wish to contact the staff team, please join the support server (<https://discord.gg/PczBt78>) or send us an email at <legal@pluralkit.me>.";
try
{
var dm = await _rest.CreateDm(account.Id);
var msg = await ctx.Rest.CreateMessage(dm.Id,
new MessageRequest { Content = messageContent }
);
}
catch (Exception)
{
await ctx.Reply(
$"{Emojis.Error} Error while sending DM.");
}
await ctx.Reply($"{Emojis.Success} Successfully sent message.");
}
}