PluralKit/PluralKit.Bot/Commands/Admin.cs

503 lines
18 KiB
C#
Raw Normal View History

using Humanizer;
using Dapper;
using SqlKata;
using Myriad.Builders;
using Myriad.Extensions;
using Myriad.Cache;
using Myriad.Rest;
using Myriad.Types;
2025-09-09 10:43:00 -04:00
using Myriad.Rest.Types.Requests;
2025-09-09 11:32:13 -04:00
using Myriad.Rest.Exceptions;
using PluralKit.Core;
namespace PluralKit.Bot;
public class Admin
{
private readonly BotConfig _botConfig;
private readonly DiscordApiClient _rest;
private readonly IDiscordCache _cache;
2025-09-09 11:32:13 -04:00
private readonly PrivateChannelService _dmCache;
2025-09-09 11:32:13 -04:00
public Admin(BotConfig botConfig, DiscordApiClient rest, IDiscordCache cache, PrivateChannelService dmCache)
{
_botConfig = botConfig;
_rest = rest;
_cache = cache;
2025-09-09 11:32:13 -04:00
_dmCache = dmCache;
}
2024-10-23 10:08:25 +13:00
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();
}
2024-10-23 10:08:25 +13:00
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();
}
2025-10-04 01:57:48 +00:00
public async Task UpdateSystemId(Context ctx, PKSystem target, string newHid)
{
ctx.AssertBotAdmin();
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}`).");
}
2025-10-04 01:57:48 +00:00
public async Task UpdateMemberId(Context ctx, PKMember target, string newHid)
{
ctx.AssertBotAdmin();
2021-08-27 11:03:47 -04:00
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}`).");
}
2021-08-27 11:03:47 -04:00
2025-10-04 01:57:48 +00:00
public async Task UpdateGroupId(Context ctx, PKGroup target, string newHid)
{
ctx.AssertBotAdmin();
var existingGroup = await ctx.Repository.GetGroupByHid(newHid);
if (existingGroup != null)
throw new PKError($"Another group already exists with ID `{newHid}`.");
2021-07-08 10:04:05 -04:00
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.");
2021-07-08 10:04:05 -04:00
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Hid = newHid });
await ctx.Reply($"{Emojis.Success} Group ID updated (`{target.Hid}` -> `{newHid}`).");
}
2021-07-08 10:04:05 -04:00
2025-10-04 01:57:48 +00:00
public async Task RerollSystemId(Context ctx, PKSystem target)
{
ctx.AssertBotAdmin();
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}`).");
}
2025-10-04 01:57:48 +00:00
public async Task RerollMemberId(Context ctx, PKMember target)
{
ctx.AssertBotAdmin();
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}`).");
}
2025-10-04 01:57:48 +00:00
public async Task RerollGroupId(Context ctx, PKGroup target)
{
ctx.AssertBotAdmin();
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}`).");
}
2025-10-04 01:57:48 +00:00
public async Task SystemMemberLimit(Context ctx, PKSystem target, int? newLimit)
{
ctx.AssertBotAdmin();
2021-07-08 10:04:05 -04:00
var config = await ctx.Repository.GetSystemConfig(target.Id);
2021-11-29 21:35:21 -05:00
var currentLimit = config.MemberLimitOverride ?? Limits.MaxMemberCount;
2025-10-04 01:57:48 +00:00
if (newLimit == null)
{
await ctx.Reply(null, await CreateEmbed(ctx, target));
return;
}
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.");
}
2025-10-04 01:57:48 +00:00
public async Task SystemGroupLimit(Context ctx, PKSystem target, int? newLimit)
{
ctx.AssertBotAdmin();
2021-08-27 11:03:47 -04:00
var config = await ctx.Repository.GetSystemConfig(target.Id);
2021-11-29 21:35:21 -05:00
var currentLimit = config.GroupLimitOverride ?? Limits.MaxGroupCount;
2025-10-04 01:57:48 +00:00
if (newLimit == null)
2021-07-08 10:04:05 -04:00
{
await ctx.Reply(null, await CreateEmbed(ctx, target));
return;
}
2021-07-08 10:04:05 -04:00
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.");
2021-07-08 10:04:05 -04:00
await ctx.Repository.UpdateSystemConfig(target.Id, new SystemConfigPatch { GroupLimitOverride = newLimit });
await ctx.Reply($"{Emojis.Success} Group limit updated.");
}
2025-10-04 01:57:48 +00:00
public async Task SystemRecover(Context ctx, string systemToken, User account, bool rerollToken)
{
ctx.AssertBotAdmin();
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 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,
});
}
2024-10-23 10:08:25 +13:00
2025-10-04 01:57:48 +00:00
public async Task SystemDelete(Context ctx, PKSystem target)
2024-10-23 10:08:25 +13:00
{
ctx.AssertBotAdmin();
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.");
}
2025-10-04 01:57:48 +00:00
public async Task AbuseLogCreate(Context ctx, User account, bool denyBotUsage, string? description)
2024-10-23 10:08:25 +13:00
{
2025-10-04 01:57:48 +00:00
ctx.AssertBotAdmin();
2024-10-23 10:08:25 +13:00
2025-10-04 01:57:48 +00:00
var abuseLog = await ctx.Repository.CreateAbuseLog(description, denyBotUsage);
2024-10-23 10:08:25 +13:00
await ctx.Repository.AddAbuseLogAccount(abuseLog.Id, account.Id);
await ctx.Reply(
$"Created new abuse log with UUID `{abuseLog.Uuid.ToString()}`.",
await CreateAbuseLogEmbed(ctx, abuseLog));
}
2025-10-04 01:57:48 +00:00
public async Task<AbuseLog?> GetAbuseLog(Context ctx, User? account, string? id)
{
ctx.AssertBotAdmin();
AbuseLog? abuseLog = null!;
if (account != null)
{
abuseLog = await ctx.Repository.GetAbuseLogByAccount(account.Id);
}
else
{
abuseLog = await ctx.Repository.GetAbuseLogByGuid(new Guid(id));
}
if (abuseLog == null)
{
await ctx.Reply($"{Emojis.Error} Could not find an existing abuse log entry for that query.");
return null;
}
return abuseLog;
}
public async Task AbuseLogShow(Context ctx, User? account, string? id)
2024-10-23 10:08:25 +13:00
{
2025-10-04 01:57:48 +00:00
ctx.AssertBotAdmin();
AbuseLog? abuseLog = await GetAbuseLog(ctx, account, id);
if (abuseLog == null)
return;
2024-10-23 10:08:25 +13:00
await ctx.Reply(null, await CreateAbuseLogEmbed(ctx, abuseLog));
}
2025-10-04 01:57:48 +00:00
public async Task AbuseLogFlagDeny(Context ctx, User? account, string? id, bool? value)
2024-10-23 10:08:25 +13:00
{
2025-10-04 01:57:48 +00:00
ctx.AssertBotAdmin();
AbuseLog? abuseLog = await GetAbuseLog(ctx, account, id);
if (abuseLog == null)
return;
if (value == null)
2024-10-23 10:08:25 +13:00
{
await ctx.Reply(
$"Bot usage is currently {(abuseLog.DenyBotUsage ? "denied" : "allowed")} "
+ $"for accounts associated with abuse log `{abuseLog.Uuid}`.");
}
else
{
if (abuseLog.DenyBotUsage != value)
2025-10-04 01:57:48 +00:00
await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { DenyBotUsage = value.Value });
2024-10-23 10:08:25 +13:00
await ctx.Reply(
2025-10-04 01:57:48 +00:00
$"Bot usage is now **{(value.Value ? "denied" : "allowed")}** "
2024-10-23 10:08:25 +13:00
+ $"for accounts associated with abuse log `{abuseLog.Uuid}`.");
}
}
2025-10-04 01:57:48 +00:00
public async Task AbuseLogDescription(Context ctx, User? account, string? id, string? description, bool clear)
2024-10-23 10:08:25 +13:00
{
2025-10-04 01:57:48 +00:00
ctx.AssertBotAdmin();
AbuseLog? abuseLog = await GetAbuseLog(ctx, account, id);
if (abuseLog == null)
return;
if (clear && await ctx.ConfirmClear("this abuse log description"))
2024-10-23 10:08:25 +13:00
{
await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { Description = null });
await ctx.Reply($"{Emojis.Success} Abuse log description cleared.");
}
2025-10-04 01:57:48 +00:00
else if (description != null)
2024-10-23 10:08:25 +13:00
{
2025-10-04 01:57:48 +00:00
await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { Description = description });
2024-10-23 10:08:25 +13:00
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());
}
}
2025-10-04 01:57:48 +00:00
public async Task AbuseLogAddUser(Context ctx, User? accountToFind, string? id, User account)
2024-10-23 10:08:25 +13:00
{
2025-10-04 01:57:48 +00:00
ctx.AssertBotAdmin();
AbuseLog? abuseLog = await GetAbuseLog(ctx, accountToFind, id);
if (abuseLog == null)
return;
2024-10-23 10:08:25 +13:00
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));
}
2025-10-04 01:57:48 +00:00
public async Task AbuseLogRemoveUser(Context ctx, User? accountToFind, string? id, User account)
2024-10-23 10:08:25 +13:00
{
2025-10-04 01:57:48 +00:00
ctx.AssertBotAdmin();
AbuseLog? abuseLog = await GetAbuseLog(ctx, accountToFind, id);
if (abuseLog == null)
return;
2024-10-23 10:08:25 +13:00
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));
}
2025-10-04 01:57:48 +00:00
public async Task AbuseLogDelete(Context ctx, User? account, string? id)
2024-10-23 10:08:25 +13:00
{
2025-10-04 01:57:48 +00:00
ctx.AssertBotAdmin();
AbuseLog? abuseLog = await GetAbuseLog(ctx, account, id);
if (abuseLog == null)
return;
2024-10-23 10:08:25 +13:00
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.");
}
2025-09-09 10:43:00 -04:00
2025-10-04 01:57:48 +00:00
public async Task SendAdminMessage(Context ctx, User account, string content)
2025-09-09 10:43:00 -04:00
{
ctx.AssertBotAdmin();
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
{
2025-09-09 11:32:13 -04:00
var dm = await _dmCache.GetOrCreateDmChannel(account.Id);
var msg = await ctx.Rest.CreateMessage(dm,
2025-09-09 10:43:00 -04:00
new MessageRequest { Content = messageContent }
);
}
2025-09-09 11:32:13 -04:00
catch (ForbiddenException)
2025-09-09 10:43:00 -04:00
{
await ctx.Reply(
$"{Emojis.Error} Error while sending DM.");
2025-09-09 11:32:13 -04:00
return;
2025-09-09 10:43:00 -04:00
}
await ctx.Reply($"{Emojis.Success} Successfully sent message.");
}
}