Port more things!

This commit is contained in:
Ske
2020-12-24 14:52:44 +01:00
parent f6fb8204bb
commit 47b16dc51b
26 changed files with 332 additions and 186 deletions

View File

@@ -1,10 +1,11 @@
using System;
using System.Threading.Tasks;
using DSharpPlus.Entities;
using Humanizer;
using Myriad.Builders;
using Myriad.Types;
using NodaTime;
using PluralKit.Core;
@@ -84,10 +85,11 @@ namespace PluralKit.Bot
await ctx.Reply($"{Emojis.Success} Autoproxy set to **{member.NameFor(ctx)}** in this server.");
}
private async Task<DiscordEmbed> CreateAutoproxyStatusEmbed(Context ctx)
private async Task<Embed> CreateAutoproxyStatusEmbed(Context ctx)
{
var commandList = "**pk;autoproxy latch** - Autoproxies as last-proxied member\n**pk;autoproxy front** - Autoproxies as current (first) fronter\n**pk;autoproxy <member>** - Autoproxies as a specific member";
var eb = new DiscordEmbedBuilder().WithTitle($"Current autoproxy status (for {ctx.Guild.Name.EscapeMarkdown()})");
var eb = new EmbedBuilder()
.Title($"Current autoproxy status (for {ctx.GuildNew.Name.EscapeMarkdown()})");
var fronters = ctx.MessageContext.LastSwitchMembers;
var relevantMember = ctx.MessageContext.AutoproxyMode switch
@@ -98,35 +100,36 @@ namespace PluralKit.Bot
};
switch (ctx.MessageContext.AutoproxyMode) {
case AutoproxyMode.Off: eb.WithDescription($"Autoproxy is currently **off** in this server. To enable it, use one of the following commands:\n{commandList}");
case AutoproxyMode.Off:
eb.Description($"Autoproxy is currently **off** in this server. To enable it, use one of the following commands:\n{commandList}");
break;
case AutoproxyMode.Front:
{
if (fronters.Length == 0)
eb.WithDescription("Autoproxy is currently set to **front mode** in this server, but there are currently no fronters registered. Use the `pk;switch` command to log a switch.");
eb.Description("Autoproxy is currently set to **front mode** in this server, but there are currently no fronters registered. Use the `pk;switch` command to log a switch.");
else
{
if (relevantMember == null)
throw new ArgumentException("Attempted to print member autoproxy status, but the linked member ID wasn't found in the database. Should be handled appropriately.");
eb.WithDescription($"Autoproxy is currently set to **front mode** in this server. The current (first) fronter is **{relevantMember.NameFor(ctx).EscapeMarkdown()}** (`{relevantMember.Hid}`). To disable, type `pk;autoproxy off`.");
eb.Description($"Autoproxy is currently set to **front mode** in this server. The current (first) fronter is **{relevantMember.NameFor(ctx).EscapeMarkdown()}** (`{relevantMember.Hid}`). To disable, type `pk;autoproxy off`.");
}
break;
}
// AutoproxyMember is never null if Mode is Member, this is just to make the compiler shut up
case AutoproxyMode.Member when relevantMember != null: {
eb.WithDescription($"Autoproxy is active for member **{relevantMember.NameFor(ctx)}** (`{relevantMember.Hid}`) in this server. To disable, type `pk;autoproxy off`.");
eb.Description($"Autoproxy is active for member **{relevantMember.NameFor(ctx)}** (`{relevantMember.Hid}`) in this server. To disable, type `pk;autoproxy off`.");
break;
}
case AutoproxyMode.Latch:
eb.WithDescription("Autoproxy is currently set to **latch mode**, meaning the *last-proxied member* will be autoproxied. To disable, type `pk;autoproxy off`.");
eb.Description("Autoproxy is currently set to **latch mode**, meaning the *last-proxied member* will be autoproxied. To disable, type `pk;autoproxy off`.");
break;
default: throw new ArgumentOutOfRangeException();
}
if (!ctx.MessageContext.AllowAutoproxy)
eb.AddField("\u200b", $"{Emojis.Note} Autoproxy is currently **disabled** for your account (<@{ctx.Author.Id}>). To enable it, use `pk;autoproxy account enable`.");
eb.Field(new("\u200b", $"{Emojis.Note} Autoproxy is currently **disabled** for your account (<@{ctx.Author.Id}>). To enable it, use `pk;autoproxy account enable`."));
return eb.Build();
}
@@ -178,7 +181,7 @@ namespace PluralKit.Bot
else
{
var statusString = ctx.MessageContext.AllowAutoproxy ? "enabled" : "disabled";
await ctx.Reply($"Autoproxy is currently **{statusString}** for account <@{ctx.Author.Id}>.", mentions: new IMention[]{});
await ctx.Reply($"Autoproxy is currently **{statusString}** for account <@{ctx.Author.Id}>.");
}
}
@@ -187,18 +190,18 @@ namespace PluralKit.Bot
var statusString = allow ? "enabled" : "disabled";
if (ctx.MessageContext.AllowAutoproxy == allow)
{
await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.Author.Id}>.", mentions: new IMention[]{});
await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.Author.Id}>.");
return;
}
var patch = new AccountPatch { AllowAutoproxy = allow };
await _db.Execute(conn => _repo.UpdateAccount(conn, ctx.Author.Id, patch));
await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>.", mentions: new IMention[]{});
await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>.");
}
private Task UpdateAutoproxy(Context ctx, AutoproxyMode autoproxyMode, MemberId? autoproxyMember)
{
var patch = new SystemGuildPatch {AutoproxyMode = autoproxyMode, AutoproxyMember = autoproxyMember};
return _db.Execute(conn => _repo.UpsertSystemGuild(conn, ctx.System.Id, ctx.Guild.Id, patch));
return _db.Execute(conn => _repo.UpsertSystemGuild(conn, ctx.System.Id, ctx.GuildNew.Id, patch));
}
}
}

View File

@@ -4,8 +4,8 @@ using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DSharpPlus;
using DSharpPlus.Entities;
using Myriad.Extensions;
using Myriad.Types;
namespace PluralKit.Bot
{
@@ -22,7 +22,7 @@ namespace PluralKit.Bot
// If we have a user @mention/ID, use their avatar
if (await ctx.MatchUser() is { } user)
{
var url = user.GetAvatarUrl(ImageFormat.Png, 256);
var url = user.AvatarUrl("png", 256);
return new ParsedImage {Url = url, Source = AvatarSource.User, SourceUser = user};
}
@@ -64,7 +64,7 @@ namespace PluralKit.Bot
{
public string Url;
public AvatarSource Source;
public DiscordUser? SourceUser;
public User? SourceUser;
}
public enum AvatarSource

View File

@@ -10,6 +10,8 @@ using DSharpPlus.Entities;
using Humanizer;
using Myriad.Builders;
using PluralKit.Core;
namespace PluralKit.Bot
@@ -194,7 +196,7 @@ namespace PluralKit.Bot
// The attachment's already right there, no need to preview it.
var hasEmbed = img.Source != AvatarSource.Attachment;
await (hasEmbed
? ctx.Reply(msg, embed: new DiscordEmbedBuilder().WithImageUrl(img.Url).Build())
? ctx.Reply(msg, embed: new EmbedBuilder().Image(new(img.Url)).Build())
: ctx.Reply(msg));
}
@@ -265,7 +267,7 @@ namespace PluralKit.Bot
var title = system.Name != null ? $"Groups of {system.Name} (`{system.Hid}`)" : $"Groups of `{system.Hid}`";
await ctx.Paginate(groups.ToAsyncEnumerable(), groups.Count, 25, title, Renderer);
Task Renderer(DiscordEmbedBuilder eb, IEnumerable<ListedGroup> page)
Task Renderer(EmbedBuilder eb, IEnumerable<ListedGroup> page)
{
eb.WithSimpleLineContent(page.Select(g =>
{
@@ -274,7 +276,7 @@ namespace PluralKit.Bot
else
return $"[`{g.Hid}`] **{g.Name.EscapeMarkdown()}** ({"member".ToQuantity(g.MemberCount)})";
}));
eb.WithFooter($"{groups.Count} total.");
eb.Footer(new($"{groups.Count} total."));
return Task.CompletedTask;
}
}

View File

@@ -3,10 +3,10 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
using DSharpPlus.Entities;
using Humanizer;
using Myriad.Builders;
using NodaTime;
using PluralKit.Core;
@@ -90,10 +90,10 @@ namespace PluralKit.Bot
await ctx.Paginate(members.ToAsyncEnumerable(), members.Count, itemsPerPage, embedTitle, Renderer);
// Base renderer, dispatches based on type
Task Renderer(DiscordEmbedBuilder eb, IEnumerable<ListedMember> page)
Task Renderer(EmbedBuilder eb, IEnumerable<ListedMember> page)
{
// Add a global footer with the filter/sort string + result count
eb.WithFooter($"{opts.CreateFilterString()}. {"result".ToQuantity(members.Count)}.");
eb.Footer(new($"{opts.CreateFilterString()}. {"result".ToQuantity(members.Count)}."));
// Then call the specific renderers
if (opts.Type == ListType.Short)
@@ -104,7 +104,7 @@ namespace PluralKit.Bot
return Task.CompletedTask;
}
void ShortRenderer(DiscordEmbedBuilder eb, IEnumerable<ListedMember> page)
void ShortRenderer(EmbedBuilder eb, IEnumerable<ListedMember> page)
{
// We may end up over the description character limit
// so run it through a helper that "makes it work" :)
@@ -122,7 +122,7 @@ namespace PluralKit.Bot
}));
}
void LongRenderer(DiscordEmbedBuilder eb, IEnumerable<ListedMember> page)
void LongRenderer(EmbedBuilder eb, IEnumerable<ListedMember> page)
{
var zone = ctx.System?.Zone ?? DateTimeZone.Utc;
foreach (var m in page)
@@ -162,7 +162,7 @@ namespace PluralKit.Bot
if (m.MemberVisibility == PrivacyLevel.Private)
profile.Append("\n*(this member is hidden)*");
eb.AddField(m.NameFor(ctx), profile.ToString().Truncate(1024));
eb.Field(new(m.NameFor(ctx), profile.ToString().Truncate(1024)));
}
}
}

View File

@@ -4,6 +4,8 @@ using System.Threading.Tasks;
using DSharpPlus.Entities;
using Myriad.Builders;
using PluralKit.Core;
namespace PluralKit.Bot
@@ -135,7 +137,7 @@ namespace PluralKit.Bot
// The attachment's already right there, no need to preview it.
var hasEmbed = avatar.Source != AvatarSource.Attachment;
return hasEmbed
? ctx.Reply(msg, embed: new DiscordEmbedBuilder().WithImageUrl(avatar.Url).Build())
? ctx.Reply(msg, embed: new EmbedBuilder().Image(new(avatar.Url)).Build())
: ctx.Reply(msg);
}

View File

@@ -13,7 +13,16 @@ using Humanizer;
using NodaTime;
using PluralKit.Core;
using DSharpPlus.Entities;
using Myriad.Builders;
using Myriad.Cache;
using Myriad.Extensions;
using Myriad.Gateway;
using Myriad.Rest;
using Myriad.Rest.Types.Requests;
using Myriad.Types;
using Permissions = DSharpPlus.Permissions;
namespace PluralKit.Bot {
public class Misc
@@ -25,8 +34,12 @@ namespace PluralKit.Bot {
private readonly EmbedService _embeds;
private readonly IDatabase _db;
private readonly ModelRepository _repo;
private readonly IDiscordCache _cache;
private readonly DiscordApiClient _rest;
private readonly Cluster _cluster;
private readonly Bot _bot;
public Misc(BotConfig botConfig, IMetrics metrics, CpuStatService cpu, ShardInfoService shards, EmbedService embeds, ModelRepository repo, IDatabase db)
public Misc(BotConfig botConfig, IMetrics metrics, CpuStatService cpu, ShardInfoService shards, EmbedService embeds, ModelRepository repo, IDatabase db, IDiscordCache cache, DiscordApiClient rest, Bot bot, Cluster cluster)
{
_botConfig = botConfig;
_metrics = metrics;
@@ -35,20 +48,26 @@ namespace PluralKit.Bot {
_embeds = embeds;
_repo = repo;
_db = db;
_cache = cache;
_rest = rest;
_bot = bot;
_cluster = cluster;
}
public async Task Invite(Context ctx)
{
var clientId = _botConfig.ClientId ?? ctx.Client.CurrentApplication.Id;
var permissions = new Permissions()
.Grant(Permissions.AddReactions)
.Grant(Permissions.AttachFiles)
.Grant(Permissions.EmbedLinks)
.Grant(Permissions.ManageMessages)
.Grant(Permissions.ManageWebhooks)
.Grant(Permissions.ReadMessageHistory)
.Grant(Permissions.SendMessages);
var invite = $"https://discord.com/oauth2/authorize?client_id={clientId}&scope=bot%20applications.commands&permissions={(long)permissions}";
var clientId = _botConfig.ClientId ?? _cluster.Application?.Id;
var permissions =
PermissionSet.AddReactions |
PermissionSet.AttachFiles |
PermissionSet.EmbedLinks |
PermissionSet.ManageMessages |
PermissionSet.ManageWebhooks |
PermissionSet.ReadMessageHistory |
PermissionSet.SendMessages;
var invite = $"https://discord.com/oauth2/authorize?client_id={clientId}&scope=bot%20applications.commands&permissions={(ulong)permissions}";
await ctx.Reply($"{Emojis.Success} Use this link to add PluralKit to your server:\n<{invite}>");
}
@@ -69,6 +88,7 @@ namespace PluralKit.Bot {
var totalSwitches = _metrics.Snapshot.GetForContext("Application").Gauges.FirstOrDefault(m => m.MultidimensionalName == CoreMetrics.SwitchCount.Name)?.Value ?? 0;
var totalMessages = _metrics.Snapshot.GetForContext("Application").Gauges.FirstOrDefault(m => m.MultidimensionalName == CoreMetrics.MessageCount.Name)?.Value ?? 0;
// TODO: shard stuff
var shardId = ctx.Shard.ShardId;
var shardTotal = ctx.Client.ShardClients.Count;
var shardUpTotal = _shards.Shards.Where(x => x.Connected).Count();
@@ -79,30 +99,31 @@ namespace PluralKit.Bot {
var shardUptime = SystemClock.Instance.GetCurrentInstant() - shardInfo.LastConnectionTime;
var embed = new DiscordEmbedBuilder();
if (messagesReceived != null) embed.AddField("Messages processed",$"{messagesReceived.OneMinuteRate * 60:F1}/m ({messagesReceived.FifteenMinuteRate * 60:F1}/m over 15m)", true);
if (messagesProxied != null) embed.AddField("Messages proxied", $"{messagesProxied.OneMinuteRate * 60:F1}/m ({messagesProxied.FifteenMinuteRate * 60:F1}/m over 15m)", true);
if (commandsRun != null) embed.AddField("Commands executed", $"{commandsRun.OneMinuteRate * 60:F1}/m ({commandsRun.FifteenMinuteRate * 60:F1}/m over 15m)", true);
var embed = new EmbedBuilder();
if (messagesReceived != null) embed.Field(new("Messages processed",$"{messagesReceived.OneMinuteRate * 60:F1}/m ({messagesReceived.FifteenMinuteRate * 60:F1}/m over 15m)", true));
if (messagesProxied != null) embed.Field(new("Messages proxied", $"{messagesProxied.OneMinuteRate * 60:F1}/m ({messagesProxied.FifteenMinuteRate * 60:F1}/m over 15m)", true));
if (commandsRun != null) embed.Field(new("Commands executed", $"{commandsRun.OneMinuteRate * 60:F1}/m ({commandsRun.FifteenMinuteRate * 60:F1}/m over 15m)", true));
embed
.AddField("Current shard", $"Shard #{shardId} (of {shardTotal} total, {shardUpTotal} are up)", true)
.AddField("Shard uptime", $"{shardUptime.FormatDuration()} ({shardInfo.DisconnectionCount} disconnections)", true)
.AddField("CPU usage", $"{_cpu.LastCpuMeasure:P1}", true)
.AddField("Memory usage", $"{memoryUsage / 1024 / 1024} MiB", true)
.AddField("Latency", $"API: {apiLatency.TotalMilliseconds:F0} ms, shard: {shardInfo.ShardLatency.Milliseconds} ms", true)
.AddField("Total numbers", $"{totalSystems:N0} systems, {totalMembers:N0} members, {totalGroups:N0} groups, {totalSwitches:N0} switches, {totalMessages:N0} messages");
await msg.ModifyAsync("", embed.Build());
.Field(new("Current shard", $"Shard #{shardId} (of {shardTotal} total, {shardUpTotal} are up)", true))
.Field(new("Shard uptime", $"{shardUptime.FormatDuration()} ({shardInfo.DisconnectionCount} disconnections)", true))
.Field(new("CPU usage", $"{_cpu.LastCpuMeasure:P1}", true))
.Field(new("Memory usage", $"{memoryUsage / 1024 / 1024} MiB", true))
.Field(new("Latency", $"API: {apiLatency.TotalMilliseconds:F0} ms, shard: {shardInfo.ShardLatency.Milliseconds} ms", true))
.Field(new("Total numbers", $"{totalSystems:N0} systems, {totalMembers:N0} members, {totalGroups:N0} groups, {totalSwitches:N0} switches, {totalMessages:N0} messages"));
await ctx.RestNew.EditMessage(msg.ChannelId, msg.Id,
new MessageEditRequest {Content = "", Embed = embed.Build()});
}
public async Task PermCheckGuild(Context ctx)
{
DiscordGuild guild;
DiscordMember senderGuildUser = null;
Guild guild;
GuildMemberPartial senderGuildUser = null;
if (ctx.Guild != null && !ctx.HasNext())
if (ctx.GuildNew != null && !ctx.HasNext())
{
guild = ctx.Guild;
senderGuildUser = (DiscordMember)ctx.Author;
guild = ctx.GuildNew;
senderGuildUser = ctx.MemberNew;
}
else
{
@@ -110,31 +131,33 @@ namespace PluralKit.Bot {
if (!ulong.TryParse(guildIdStr, out var guildId))
throw new PKSyntaxError($"Could not parse {guildIdStr.AsCode()} as an ID.");
guild = ctx.Client.GetGuild(guildId);
if (guild != null) senderGuildUser = await guild.GetMember(ctx.Author.Id);
if (guild == null || senderGuildUser == null) throw Errors.GuildNotFound(guildId);
guild = await _rest.GetGuild(guildId);
if (guild != null)
senderGuildUser = await _rest.GetGuildMember(guildId, ctx.AuthorNew.Id);
if (guild == null || senderGuildUser == null)
throw Errors.GuildNotFound(guildId);
}
var requiredPermissions = new []
{
Permissions.AccessChannels,
Permissions.SendMessages,
Permissions.AddReactions,
Permissions.AttachFiles,
Permissions.EmbedLinks,
Permissions.ManageMessages,
Permissions.ManageWebhooks
PermissionSet.ViewChannel,
PermissionSet.SendMessages,
PermissionSet.AddReactions,
PermissionSet.AttachFiles,
PermissionSet.EmbedLinks,
PermissionSet.ManageMessages,
PermissionSet.ManageWebhooks
};
// Loop through every channel and group them by sets of permissions missing
var permissionsMissing = new Dictionary<ulong, List<DiscordChannel>>();
var permissionsMissing = new Dictionary<ulong, List<Channel>>();
var hiddenChannels = 0;
foreach (var channel in await guild.GetChannelsAsync())
foreach (var channel in await _rest.GetGuildChannels(guild.Id))
{
var botPermissions = channel.BotPermissions();
var botPermissions = _bot.PermissionsIn(channel.Id);
var userPermissions = PermissionExtensions.PermissionsFor(guild, channel, ctx.AuthorNew.Id, senderGuildUser.Roles);
var userPermissions = senderGuildUser.PermissionsIn(channel);
if ((userPermissions & Permissions.AccessChannels) == 0)
if ((userPermissions & PermissionSet.ViewChannel) == 0)
{
// If the user can't see this channel, don't calculate permissions for it
// (to prevent info-leaking, mostly)
@@ -154,18 +177,18 @@ namespace PluralKit.Bot {
// This means we can check if the dict is empty to see if all channels are proxyable
if (missingPermissionField != 0)
{
permissionsMissing.TryAdd(missingPermissionField, new List<DiscordChannel>());
permissionsMissing.TryAdd(missingPermissionField, new List<Channel>());
permissionsMissing[missingPermissionField].Add(channel);
}
}
// Generate the output embed
var eb = new DiscordEmbedBuilder()
.WithTitle($"Permission check for **{guild.Name}**");
var eb = new EmbedBuilder()
.Title($"Permission check for **{guild.Name}**");
if (permissionsMissing.Count == 0)
{
eb.WithDescription($"No errors found, all channels proxyable :)").WithColor(DiscordUtils.Green);
eb.Description($"No errors found, all channels proxyable :)").Color((uint?) DiscordUtils.Green.Value);
}
else
{
@@ -173,18 +196,19 @@ namespace PluralKit.Bot {
{
// Each missing permission field can have multiple missing channels
// so we extract them all and generate a comma-separated list
// TODO: port ToPermissionString?
var missingPermissionNames = ((Permissions)missingPermissionField).ToPermissionString();
var channelsList = string.Join("\n", channels
.OrderBy(c => c.Position)
.Select(c => $"#{c.Name}"));
eb.AddField($"Missing *{missingPermissionNames}*", channelsList.Truncate(1000));
eb.WithColor(DiscordUtils.Red);
eb.Field(new($"Missing *{missingPermissionNames}*", channelsList.Truncate(1000)));
eb.Color((uint?) DiscordUtils.Red.Value);
}
}
if (hiddenChannels > 0)
eb.WithFooter($"{"channel".ToQuantity(hiddenChannels)} were ignored as you do not have view access to them.");
eb.Footer(new($"{"channel".ToQuantity(hiddenChannels)} were ignored as you do not have view access to them."));
// Send! :)
await ctx.Reply(embed: eb.Build());

View File

@@ -123,7 +123,7 @@ namespace PluralKit.Bot
{
if (lastCategory != channel!.ParentId && fieldValue.Length > 0)
{
eb.AddField(CategoryName(lastCategory), fieldValue.ToString());
eb.Field(new(CategoryName(lastCategory), fieldValue.ToString()));
fieldValue.Clear();
}
else fieldValue.Append("\n");
@@ -132,7 +132,7 @@ namespace PluralKit.Bot
lastCategory = channel.ParentId;
}
eb.AddField(CategoryName(lastCategory), fieldValue.ToString());
eb.Field(new(CategoryName(lastCategory), fieldValue.ToString()));
return Task.CompletedTask;
});

View File

@@ -4,6 +4,8 @@ using System.Threading.Tasks;
using DSharpPlus.Entities;
using Myriad.Builders;
using NodaTime;
using NodaTime.Text;
using NodaTime.TimeZones;
@@ -150,7 +152,7 @@ namespace PluralKit.Bot
// The attachment's already right there, no need to preview it.
var hasEmbed = img.Source != AvatarSource.Attachment;
await (hasEmbed
? ctx.Reply(msg, embed: new DiscordEmbedBuilder().WithImageUrl(img.Url).Build())
? ctx.Reply(msg, embed: new EmbedBuilder().Image(new(img.Url)).Build())
: ctx.Reply(msg));
}

View File

@@ -98,9 +98,11 @@ namespace PluralKit.Bot
stringToAdd =
$"**{membersStr}** ({sw.Timestamp.FormatZoned(system.Zone)}, {switchSince.FormatDuration()} ago)\n";
}
try // Unfortunately the only way to test DiscordEmbedBuilder.Description max length is this
{
builder.Description += stringToAdd;
// TODO: what is this??
// builder.Description += stringToAdd;
}
catch (ArgumentException)
{

View File

@@ -1,7 +1,8 @@
using System.Linq;
using System.Threading.Tasks;
using DSharpPlus.Entities;
using Myriad.Extensions;
using Myriad.Rest.Types;
using PluralKit.Core;
@@ -33,8 +34,8 @@ namespace PluralKit.Bot
if (existingAccount != null)
throw Errors.AccountInOtherSystem(existingAccount);
var msg = $"{account.Mention}, please confirm the link by clicking the {Emojis.Success} reaction on this message.";
var mentions = new IMention[] { new UserMention(account) };
var msg = $"{account.Mention()}, please confirm the link by clicking the {Emojis.Success} reaction on this message.";
var mentions = new AllowedMentions {Users = new[] {account.Id}};
if (!await ctx.PromptYesNo(msg, user: account, mentions: mentions, matchFlag: false)) throw Errors.MemberLinkCancelled;
await _repo.AddAccount(conn, ctx.System.Id, account.Id);
await ctx.Reply($"{Emojis.Success} Account linked to system.");