mirror of
https://github.com/PluralKit/PluralKit.git
synced 2026-02-09 07:17:56 +00:00
feat: upgrade to .NET 6, refactor everything
This commit is contained in:
parent
d28e99ba43
commit
1918c56937
314 changed files with 27954 additions and 27966 deletions
|
|
@ -1,143 +1,145 @@
|
|||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class Admin
|
||||
{
|
||||
public class Admin
|
||||
private readonly BotConfig _botConfig;
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
|
||||
public Admin(BotConfig botConfig, IDatabase db, ModelRepository repo)
|
||||
{
|
||||
private readonly BotConfig _botConfig;
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
_botConfig = botConfig;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public Admin(BotConfig botConfig, IDatabase db, ModelRepository repo)
|
||||
public async Task UpdateSystemId(Context ctx)
|
||||
{
|
||||
ctx.AssertBotAdmin();
|
||||
|
||||
var target = await ctx.MatchSystem();
|
||||
if (target == null)
|
||||
throw new PKError("Unknown system.");
|
||||
|
||||
var newHid = ctx.PopArgument();
|
||||
if (!Regex.IsMatch(newHid, "^[a-z]{5}$"))
|
||||
throw new PKError($"Invalid new system ID `{newHid}`.");
|
||||
|
||||
var existingSystem = await _repo.GetSystemByHid(newHid);
|
||||
if (existingSystem != null)
|
||||
throw new PKError($"Another system already exists with ID `{newHid}`.");
|
||||
|
||||
if (!await ctx.PromptYesNo($"Change system ID of `{target.Hid}` to `{newHid}`?", "Change"))
|
||||
throw new PKError("ID change cancelled.");
|
||||
|
||||
await _repo.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 newHid = ctx.PopArgument();
|
||||
if (!Regex.IsMatch(newHid, "^[a-z]{5}$"))
|
||||
throw new PKError($"Invalid new member ID `{newHid}`.");
|
||||
|
||||
var existingMember = await _repo.GetMemberByHid(newHid);
|
||||
if (existingMember != null)
|
||||
throw new PKError($"Another member already exists with ID `{newHid}`.");
|
||||
|
||||
if (!await ctx.PromptYesNo(
|
||||
$"Change member ID of **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`) to `{newHid}`?",
|
||||
"Change"
|
||||
))
|
||||
throw new PKError("ID change cancelled.");
|
||||
|
||||
await _repo.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 newHid = ctx.PopArgument();
|
||||
if (!Regex.IsMatch(newHid, "^[a-z]{5}$"))
|
||||
throw new PKError($"Invalid new group ID `{newHid}`.");
|
||||
|
||||
var existingGroup = await _repo.GetGroupByHid(newHid);
|
||||
if (existingGroup != null)
|
||||
throw new PKError($"Another group already exists with ID `{newHid}`.");
|
||||
|
||||
if (!await ctx.PromptYesNo($"Change group ID of **{target.Name}** (`{target.Hid}`) to `{newHid}`?",
|
||||
"Change"
|
||||
))
|
||||
throw new PKError("ID change cancelled.");
|
||||
|
||||
await _repo.UpdateGroup(target.Id, new GroupPatch { Hid = newHid });
|
||||
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 currentLimit = target.MemberLimitOverride ?? Limits.MaxMemberCount;
|
||||
if (!ctx.HasNext())
|
||||
{
|
||||
_botConfig = botConfig;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
await ctx.Reply($"Current member limit is **{currentLimit}** members.");
|
||||
return;
|
||||
}
|
||||
|
||||
public async Task UpdateSystemId(Context ctx)
|
||||
var newLimitStr = ctx.PopArgument();
|
||||
if (!int.TryParse(newLimitStr, out var newLimit))
|
||||
throw new PKError($"Couldn't parse `{newLimitStr}` as number.");
|
||||
|
||||
if (!await ctx.PromptYesNo($"Update member limit from **{currentLimit}** to **{newLimit}**?", "Update"))
|
||||
throw new PKError("Member limit change cancelled.");
|
||||
|
||||
await _repo.UpdateSystem(target.Id, new SystemPatch { 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 currentLimit = target.GroupLimitOverride ?? Limits.MaxGroupCount;
|
||||
if (!ctx.HasNext())
|
||||
{
|
||||
ctx.AssertBotAdmin();
|
||||
|
||||
var target = await ctx.MatchSystem();
|
||||
if (target == null)
|
||||
throw new PKError("Unknown system.");
|
||||
|
||||
var newHid = ctx.PopArgument();
|
||||
if (!Regex.IsMatch(newHid, "^[a-z]{5}$"))
|
||||
throw new PKError($"Invalid new system ID `{newHid}`.");
|
||||
|
||||
var existingSystem = await _repo.GetSystemByHid(newHid);
|
||||
if (existingSystem != null)
|
||||
throw new PKError($"Another system already exists with ID `{newHid}`.");
|
||||
|
||||
if (!await ctx.PromptYesNo($"Change system ID of `{target.Hid}` to `{newHid}`?", "Change"))
|
||||
throw new PKError("ID change cancelled.");
|
||||
|
||||
await _repo.UpdateSystem(target.Id, new() { Hid = newHid });
|
||||
await ctx.Reply($"{Emojis.Success} System ID updated (`{target.Hid}` -> `{newHid}`).");
|
||||
await ctx.Reply($"Current group limit is **{currentLimit}** groups.");
|
||||
return;
|
||||
}
|
||||
|
||||
public async Task UpdateMemberId(Context ctx)
|
||||
{
|
||||
ctx.AssertBotAdmin();
|
||||
var newLimitStr = ctx.PopArgument();
|
||||
if (!int.TryParse(newLimitStr, out var newLimit))
|
||||
throw new PKError($"Couldn't parse `{newLimitStr}` as number.");
|
||||
|
||||
var target = await ctx.MatchMember();
|
||||
if (target == null)
|
||||
throw new PKError("Unknown member.");
|
||||
if (!await ctx.PromptYesNo($"Update group limit from **{currentLimit}** to **{newLimit}**?", "Update"))
|
||||
throw new PKError("Group limit change cancelled.");
|
||||
|
||||
var newHid = ctx.PopArgument();
|
||||
if (!Regex.IsMatch(newHid, "^[a-z]{5}$"))
|
||||
throw new PKError($"Invalid new member ID `{newHid}`.");
|
||||
|
||||
var existingMember = await _repo.GetMemberByHid(newHid);
|
||||
if (existingMember != null)
|
||||
throw new PKError($"Another member already exists with ID `{newHid}`.");
|
||||
|
||||
if (!await ctx.PromptYesNo($"Change member ID of **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`) to `{newHid}`?", "Change"))
|
||||
throw new PKError("ID change cancelled.");
|
||||
|
||||
await _repo.UpdateMember(target.Id, new() { 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 newHid = ctx.PopArgument();
|
||||
if (!Regex.IsMatch(newHid, "^[a-z]{5}$"))
|
||||
throw new PKError($"Invalid new group ID `{newHid}`.");
|
||||
|
||||
var existingGroup = await _repo.GetGroupByHid(newHid);
|
||||
if (existingGroup != null)
|
||||
throw new PKError($"Another group already exists with ID `{newHid}`.");
|
||||
|
||||
if (!await ctx.PromptYesNo($"Change group ID of **{target.Name}** (`{target.Hid}`) to `{newHid}`?", "Change"))
|
||||
throw new PKError("ID change cancelled.");
|
||||
|
||||
await _repo.UpdateGroup(target.Id, new() { Hid = newHid });
|
||||
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 currentLimit = target.MemberLimitOverride ?? Limits.MaxMemberCount;
|
||||
if (!ctx.HasNext())
|
||||
{
|
||||
await ctx.Reply($"Current member limit is **{currentLimit}** members.");
|
||||
return;
|
||||
}
|
||||
|
||||
var newLimitStr = ctx.PopArgument();
|
||||
if (!int.TryParse(newLimitStr, out var newLimit))
|
||||
throw new PKError($"Couldn't parse `{newLimitStr}` as number.");
|
||||
|
||||
if (!await ctx.PromptYesNo($"Update member limit from **{currentLimit}** to **{newLimit}**?", "Update"))
|
||||
throw new PKError("Member limit change cancelled.");
|
||||
|
||||
await _repo.UpdateSystem(target.Id, new() { 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 currentLimit = target.GroupLimitOverride ?? Limits.MaxGroupCount;
|
||||
if (!ctx.HasNext())
|
||||
{
|
||||
await ctx.Reply($"Current group limit is **{currentLimit}** groups.");
|
||||
return;
|
||||
}
|
||||
|
||||
var newLimitStr = ctx.PopArgument();
|
||||
if (!int.TryParse(newLimitStr, out var newLimit))
|
||||
throw new PKError($"Couldn't parse `{newLimitStr}` as number.");
|
||||
|
||||
if (!await ctx.PromptYesNo($"Update group limit from **{currentLimit}** to **{newLimit}**?", "Update"))
|
||||
throw new PKError("Group limit change cancelled.");
|
||||
|
||||
await _repo.UpdateSystem(target.Id, new() { GroupLimitOverride = newLimit });
|
||||
await ctx.Reply($"{Emojis.Success} Group limit updated.");
|
||||
}
|
||||
await _repo.UpdateSystem(target.Id, new SystemPatch { GroupLimitOverride = newLimit });
|
||||
await ctx.Reply($"{Emojis.Success} Group limit updated.");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Myriad.Extensions;
|
||||
using Myriad.Rest.Exceptions;
|
||||
|
|
@ -9,148 +7,145 @@ using Myriad.Types;
|
|||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class Api
|
||||
{
|
||||
public class Api
|
||||
private static readonly Regex _webhookRegex =
|
||||
new("https://(?:\\w+.)?discord(?:app)?.com/api(?:/v.*)?/webhooks/(.*)");
|
||||
|
||||
private readonly DispatchService _dispatch;
|
||||
private readonly ModelRepository _repo;
|
||||
|
||||
public Api(ModelRepository repo, DispatchService dispatch)
|
||||
{
|
||||
private readonly ModelRepository _repo;
|
||||
private readonly DispatchService _dispatch;
|
||||
private static readonly Regex _webhookRegex = new("https://(?:\\w+.)?discord(?:app)?.com/api(?:/v.*)?/webhooks/(.*)");
|
||||
public Api(ModelRepository repo, DispatchService dispatch)
|
||||
_repo = repo;
|
||||
_dispatch = dispatch;
|
||||
}
|
||||
|
||||
public async Task GetToken(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
// Get or make a token
|
||||
var token = ctx.System.Token ?? await MakeAndSetNewToken(ctx.System);
|
||||
|
||||
try
|
||||
{
|
||||
_repo = repo;
|
||||
_dispatch = dispatch;
|
||||
}
|
||||
|
||||
public async Task GetToken(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
// Get or make a token
|
||||
var token = ctx.System.Token ?? await MakeAndSetNewToken(ctx.System);
|
||||
|
||||
try
|
||||
{
|
||||
// DM the user a security disclaimer, and then the token in a separate message (for easy copying on mobile)
|
||||
var dm = await ctx.Cache.GetOrCreateDmChannel(ctx.Rest, ctx.Author.Id);
|
||||
await ctx.Rest.CreateMessage(dm.Id, new MessageRequest
|
||||
// DM the user a security disclaimer, and then the token in a separate message (for easy copying on mobile)
|
||||
var dm = await ctx.Cache.GetOrCreateDmChannel(ctx.Rest, ctx.Author.Id);
|
||||
await ctx.Rest.CreateMessage(dm.Id,
|
||||
new MessageRequest
|
||||
{
|
||||
Content = $"{Emojis.Warn} Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure. If it leaks or you need a new one, you can invalidate this one with `pk;token refresh`.\n\nYour token is below:"
|
||||
Content = $"{Emojis.Warn} Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure."
|
||||
+ $" If it leaks or you need a new one, you can invalidate this one with `pk;token refresh`.\n\nYour token is below:"
|
||||
});
|
||||
await ctx.Rest.CreateMessage(dm.Id, new MessageRequest { Content = token });
|
||||
await ctx.Rest.CreateMessage(dm.Id, new MessageRequest { Content = token });
|
||||
|
||||
// If we're not already in a DM, reply with a reminder to check
|
||||
if (ctx.Channel.Type != Channel.ChannelType.Dm)
|
||||
await ctx.Reply($"{Emojis.Success} Check your DMs!");
|
||||
}
|
||||
catch (ForbiddenException)
|
||||
{
|
||||
// Can't check for permission errors beforehand, so have to handle here :/
|
||||
if (ctx.Channel.Type != Channel.ChannelType.Dm)
|
||||
await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?");
|
||||
}
|
||||
// If we're not already in a DM, reply with a reminder to check
|
||||
if (ctx.Channel.Type != Channel.ChannelType.Dm)
|
||||
await ctx.Reply($"{Emojis.Success} Check your DMs!");
|
||||
}
|
||||
catch (ForbiddenException)
|
||||
{
|
||||
// Can't check for permission errors beforehand, so have to handle here :/
|
||||
if (ctx.Channel.Type != Channel.ChannelType.Dm)
|
||||
await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> MakeAndSetNewToken(PKSystem system)
|
||||
{
|
||||
system = await _repo.UpdateSystem(system.Id, new SystemPatch { Token = StringUtils.GenerateToken() });
|
||||
return system.Token;
|
||||
}
|
||||
|
||||
public async Task RefreshToken(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
if (ctx.System.Token == null)
|
||||
{
|
||||
// If we don't have a token, call the other method instead
|
||||
// This does pretty much the same thing, except words the messages more appropriately for that :)
|
||||
await GetToken(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
private async Task<string> MakeAndSetNewToken(PKSystem system)
|
||||
try
|
||||
{
|
||||
system = await _repo.UpdateSystem(system.Id, new() { Token = StringUtils.GenerateToken() });
|
||||
return system.Token;
|
||||
}
|
||||
|
||||
public async Task RefreshToken(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
if (ctx.System.Token == null)
|
||||
{
|
||||
// If we don't have a token, call the other method instead
|
||||
// This does pretty much the same thing, except words the messages more appropriately for that :)
|
||||
await GetToken(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// DM the user an invalidation disclaimer, and then the token in a separate message (for easy copying on mobile)
|
||||
var dm = await ctx.Cache.GetOrCreateDmChannel(ctx.Rest, ctx.Author.Id);
|
||||
await ctx.Rest.CreateMessage(dm.Id, new MessageRequest
|
||||
// DM the user an invalidation disclaimer, and then the token in a separate message (for easy copying on mobile)
|
||||
var dm = await ctx.Cache.GetOrCreateDmChannel(ctx.Rest, ctx.Author.Id);
|
||||
await ctx.Rest.CreateMessage(dm.Id,
|
||||
new MessageRequest
|
||||
{
|
||||
Content = $"{Emojis.Warn} Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\n\nYour token is below:"
|
||||
});
|
||||
|
||||
// Make the new token after sending the first DM; this ensures if we can't DM, we also don't end up
|
||||
// breaking their existing token as a side effect :)
|
||||
var token = await MakeAndSetNewToken(ctx.System);
|
||||
await ctx.Rest.CreateMessage(dm.Id, new MessageRequest { Content = token });
|
||||
// Make the new token after sending the first DM; this ensures if we can't DM, we also don't end up
|
||||
// breaking their existing token as a side effect :)
|
||||
var token = await MakeAndSetNewToken(ctx.System);
|
||||
await ctx.Rest.CreateMessage(dm.Id, new MessageRequest { Content = token });
|
||||
|
||||
// If we're not already in a DM, reply with a reminder to check
|
||||
if (ctx.Channel.Type != Channel.ChannelType.Dm)
|
||||
await ctx.Reply($"{Emojis.Success} Check your DMs!");
|
||||
}
|
||||
catch (ForbiddenException)
|
||||
{
|
||||
// Can't check for permission errors beforehand, so have to handle here :/
|
||||
if (ctx.Channel.Type != Channel.ChannelType.Dm)
|
||||
await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?");
|
||||
}
|
||||
// If we're not already in a DM, reply with a reminder to check
|
||||
if (ctx.Channel.Type != Channel.ChannelType.Dm)
|
||||
await ctx.Reply($"{Emojis.Success} Check your DMs!");
|
||||
}
|
||||
|
||||
public async Task SystemWebhook(Context ctx)
|
||||
catch (ForbiddenException)
|
||||
{
|
||||
ctx.CheckSystem().CheckDMContext();
|
||||
|
||||
if (!ctx.HasNext(false))
|
||||
{
|
||||
if (ctx.System.WebhookUrl == null)
|
||||
await ctx.Reply("Your system does not have a webhook URL set. Set one with `pk;system webhook <url>`!");
|
||||
else
|
||||
await ctx.Reply($"Your system's webhook URL is <{ctx.System.WebhookUrl}>.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (await ctx.MatchClear("your system's webhook URL"))
|
||||
{
|
||||
await _repo.UpdateSystem(ctx.System.Id, new()
|
||||
{
|
||||
WebhookUrl = null,
|
||||
WebhookToken = null,
|
||||
});
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} System webhook URL removed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var newUrl = ctx.RemainderOrNull();
|
||||
if (!await DispatchExt.ValidateUri(newUrl))
|
||||
throw new PKError($"The URL {newUrl.AsCode()} is invalid or I cannot access it. Are you sure this is a valid, publicly accessible URL?");
|
||||
|
||||
if (_webhookRegex.IsMatch(newUrl))
|
||||
throw new PKError("PluralKit does not currently support setting a Discord webhook URL as your system's webhook URL.");
|
||||
|
||||
try
|
||||
{
|
||||
await _dispatch.DoPostRequest(ctx.System.Id, newUrl, null, true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new PKError($"Could not verify that the new URL is working: {e.Message}");
|
||||
}
|
||||
|
||||
var newToken = StringUtils.GenerateToken();
|
||||
|
||||
await _repo.UpdateSystem(ctx.System.Id, new()
|
||||
{
|
||||
WebhookUrl = newUrl,
|
||||
WebhookToken = newToken,
|
||||
});
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} Successfully the new webhook URL for your system."
|
||||
+ $"\n\n{Emojis.Warn} The following token is used to authenticate requests from PluralKit to you."
|
||||
+ " If it leaks, you should clear and re-set the webhook URL to get a new token."
|
||||
+ "\ntodo: add link to docs or something"
|
||||
);
|
||||
|
||||
await ctx.Reply(newToken);
|
||||
// Can't check for permission errors beforehand, so have to handle here :/
|
||||
if (ctx.Channel.Type != Channel.ChannelType.Dm)
|
||||
await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SystemWebhook(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem().CheckDMContext();
|
||||
|
||||
if (!ctx.HasNext(false))
|
||||
{
|
||||
if (ctx.System.WebhookUrl == null)
|
||||
await ctx.Reply("Your system does not have a webhook URL set. Set one with `pk;system webhook <url>`!");
|
||||
else
|
||||
await ctx.Reply($"Your system's webhook URL is <{ctx.System.WebhookUrl}>.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (await ctx.MatchClear("your system's webhook URL"))
|
||||
{
|
||||
await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { WebhookUrl = null, WebhookToken = null });
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} System webhook URL removed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var newUrl = ctx.RemainderOrNull();
|
||||
if (!await DispatchExt.ValidateUri(newUrl))
|
||||
throw new PKError($"The URL {newUrl.AsCode()} is invalid or I cannot access it. Are you sure this is a valid, publicly accessible URL?");
|
||||
|
||||
if (_webhookRegex.IsMatch(newUrl))
|
||||
throw new PKError("PluralKit does not currently support setting a Discord webhook URL as your system's webhook URL.");
|
||||
|
||||
try
|
||||
{
|
||||
await _dispatch.DoPostRequest(ctx.System.Id, newUrl, null, true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new PKError($"Could not verify that the new URL is working: {e.Message}");
|
||||
}
|
||||
|
||||
var newToken = StringUtils.GenerateToken();
|
||||
|
||||
await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { WebhookUrl = newUrl, WebhookToken = newToken });
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} Successfully the new webhook URL for your system."
|
||||
+ $"\n\n{Emojis.Warn} The following token is used to authenticate requests from PluralKit to you."
|
||||
+ " If it leaks, you should clear and re-set the webhook URL to get a new token."
|
||||
+ "\ntodo: add link to docs or something"
|
||||
);
|
||||
|
||||
await ctx.Reply(newToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,3 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Humanizer;
|
||||
|
||||
using Myriad.Builders;
|
||||
|
|
@ -10,214 +7,243 @@ using NodaTime;
|
|||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class Autoproxy
|
||||
{
|
||||
public class Autoproxy
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
|
||||
public Autoproxy(IDatabase db, ModelRepository repo)
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public Autoproxy(IDatabase db, ModelRepository repo)
|
||||
public async Task SetAutoproxyMode(Context ctx)
|
||||
{
|
||||
// no need to check account here, it's already done at CommandTree
|
||||
ctx.CheckGuildContext();
|
||||
|
||||
if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove"))
|
||||
await AutoproxyOff(ctx);
|
||||
else if (ctx.Match("latch", "last", "proxy", "stick", "sticky"))
|
||||
await AutoproxyLatch(ctx);
|
||||
else if (ctx.Match("front", "fronter", "switch"))
|
||||
await AutoproxyFront(ctx);
|
||||
else if (ctx.Match("member"))
|
||||
throw new PKSyntaxError("Member-mode autoproxy must target a specific member. Use the `pk;autoproxy <member>` command, where `member` is the name or ID of a member in your system.");
|
||||
else if (await ctx.MatchMember() is PKMember member)
|
||||
await AutoproxyMember(ctx, member);
|
||||
else if (!ctx.HasNext())
|
||||
await ctx.Reply(embed: await CreateAutoproxyStatusEmbed(ctx));
|
||||
else
|
||||
throw new PKSyntaxError($"Invalid autoproxy mode {ctx.PopArgument().AsCode()}.");
|
||||
}
|
||||
|
||||
private async Task AutoproxyOff(Context ctx)
|
||||
{
|
||||
if (ctx.MessageContext.AutoproxyMode == AutoproxyMode.Off)
|
||||
{
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
await ctx.Reply($"{Emojis.Note} Autoproxy is already off in this server.");
|
||||
}
|
||||
|
||||
public async Task SetAutoproxyMode(Context ctx)
|
||||
else
|
||||
{
|
||||
// no need to check account here, it's already done at CommandTree
|
||||
ctx.CheckGuildContext();
|
||||
|
||||
if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove"))
|
||||
await AutoproxyOff(ctx);
|
||||
else if (ctx.Match("latch", "last", "proxy", "stick", "sticky"))
|
||||
await AutoproxyLatch(ctx);
|
||||
else if (ctx.Match("front", "fronter", "switch"))
|
||||
await AutoproxyFront(ctx);
|
||||
else if (ctx.Match("member"))
|
||||
throw new PKSyntaxError("Member-mode autoproxy must target a specific member. Use the `pk;autoproxy <member>` command, where `member` is the name or ID of a member in your system.");
|
||||
else if (await ctx.MatchMember() is PKMember member)
|
||||
await AutoproxyMember(ctx, member);
|
||||
else if (!ctx.HasNext())
|
||||
await ctx.Reply(embed: await CreateAutoproxyStatusEmbed(ctx));
|
||||
else
|
||||
throw new PKSyntaxError($"Invalid autoproxy mode {ctx.PopArgument().AsCode()}.");
|
||||
}
|
||||
|
||||
private async Task AutoproxyOff(Context ctx)
|
||||
{
|
||||
if (ctx.MessageContext.AutoproxyMode == AutoproxyMode.Off)
|
||||
await ctx.Reply($"{Emojis.Note} Autoproxy is already off in this server.");
|
||||
else
|
||||
{
|
||||
await UpdateAutoproxy(ctx, AutoproxyMode.Off, null);
|
||||
await ctx.Reply($"{Emojis.Success} Autoproxy turned off in this server.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AutoproxyLatch(Context ctx)
|
||||
{
|
||||
if (ctx.MessageContext.AutoproxyMode == AutoproxyMode.Latch)
|
||||
await ctx.Reply($"{Emojis.Note} Autoproxy is already set to latch mode in this server. If you want to disable autoproxying, use `pk;autoproxy off`.");
|
||||
else
|
||||
{
|
||||
await UpdateAutoproxy(ctx, AutoproxyMode.Latch, null);
|
||||
await ctx.Reply($"{Emojis.Success} Autoproxy set to latch mode in this server. Messages will now be autoproxied using the *last-proxied member* in this server.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AutoproxyFront(Context ctx)
|
||||
{
|
||||
if (ctx.MessageContext.AutoproxyMode == AutoproxyMode.Front)
|
||||
await ctx.Reply($"{Emojis.Note} Autoproxy is already set to front mode in this server. If you want to disable autoproxying, use `pk;autoproxy off`.");
|
||||
else
|
||||
{
|
||||
await UpdateAutoproxy(ctx, AutoproxyMode.Front, null);
|
||||
await ctx.Reply($"{Emojis.Success} Autoproxy set to front mode in this server. Messages will now be autoproxied using the *current first fronter*, if any.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AutoproxyMember(Context ctx, PKMember member)
|
||||
{
|
||||
ctx.CheckOwnMember(member);
|
||||
|
||||
await UpdateAutoproxy(ctx, AutoproxyMode.Member, member.Id);
|
||||
await ctx.Reply($"{Emojis.Success} Autoproxy set to **{member.NameFor(ctx)}** in this server.");
|
||||
}
|
||||
|
||||
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 EmbedBuilder()
|
||||
.Title($"Current autoproxy status (for {ctx.Guild.Name.EscapeMarkdown()})");
|
||||
|
||||
var fronters = ctx.MessageContext.LastSwitchMembers;
|
||||
var relevantMember = ctx.MessageContext.AutoproxyMode switch
|
||||
{
|
||||
AutoproxyMode.Front => fronters.Length > 0 ? await _repo.GetMember(fronters[0]) : null,
|
||||
AutoproxyMode.Member => await _repo.GetMember(ctx.MessageContext.AutoproxyMember.Value),
|
||||
_ => null
|
||||
};
|
||||
|
||||
switch (ctx.MessageContext.AutoproxyMode)
|
||||
{
|
||||
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.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.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.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.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.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();
|
||||
}
|
||||
|
||||
public async Task AutoproxyTimeout(Context ctx)
|
||||
{
|
||||
if (!ctx.HasNext())
|
||||
{
|
||||
var timeout = ctx.System.LatchTimeout.HasValue
|
||||
? Duration.FromSeconds(ctx.System.LatchTimeout.Value)
|
||||
: (Duration?)null;
|
||||
|
||||
if (timeout == null)
|
||||
await ctx.Reply($"You do not have a custom autoproxy timeout duration set. The default latch timeout duration is {ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}.");
|
||||
else if (timeout == Duration.Zero)
|
||||
await ctx.Reply("Latch timeout is currently **disabled** for your system. Latch mode autoproxy will never time out.");
|
||||
else
|
||||
await ctx.Reply($"The current latch timeout duration for your system is {timeout.Value.ToTimeSpan().Humanize(4)}.");
|
||||
return;
|
||||
}
|
||||
|
||||
Duration? newTimeout;
|
||||
Duration overflow = Duration.Zero;
|
||||
if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove")) newTimeout = Duration.Zero;
|
||||
else if (ctx.Match("reset", "default")) newTimeout = null;
|
||||
else
|
||||
{
|
||||
var timeoutStr = ctx.RemainderOrNull();
|
||||
var timeoutPeriod = DateUtils.ParsePeriod(timeoutStr);
|
||||
if (timeoutPeriod == null) throw new PKError($"Could not parse '{timeoutStr}' as a valid duration. Try using a syntax such as \"3h5m\" (i.e. 3 hours and 5 minutes).");
|
||||
if (timeoutPeriod.Value.TotalHours > 100000)
|
||||
{
|
||||
// sanity check to prevent seconds overflow if someone types in 999999999
|
||||
overflow = timeoutPeriod.Value;
|
||||
newTimeout = Duration.Zero;
|
||||
}
|
||||
else newTimeout = timeoutPeriod;
|
||||
}
|
||||
|
||||
await _repo.UpdateSystem(ctx.System.Id, new() { LatchTimeout = (int?)newTimeout?.TotalSeconds });
|
||||
|
||||
if (newTimeout == null)
|
||||
await ctx.Reply($"{Emojis.Success} Latch timeout reset to default ({ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}).");
|
||||
else if (newTimeout == Duration.Zero && overflow != Duration.Zero)
|
||||
await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out. ({overflow.ToTimeSpan().Humanize(4)} is too long)");
|
||||
else if (newTimeout == Duration.Zero)
|
||||
await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out.");
|
||||
else
|
||||
await ctx.Reply($"{Emojis.Success} Latch timeout set to {newTimeout.Value!.ToTimeSpan().Humanize(4)}.");
|
||||
}
|
||||
|
||||
public async Task AutoproxyAccount(Context ctx)
|
||||
{
|
||||
// todo: this might be useful elsewhere, consider moving it to ctx.MatchToggle
|
||||
if (ctx.Match("enable", "on"))
|
||||
await AutoproxyEnableDisable(ctx, true);
|
||||
else if (ctx.Match("disable", "off"))
|
||||
await AutoproxyEnableDisable(ctx, false);
|
||||
else if (ctx.HasNext())
|
||||
throw new PKSyntaxError("You must pass either \"on\" or \"off\".");
|
||||
else
|
||||
{
|
||||
var statusString = ctx.MessageContext.AllowAutoproxy ? "enabled" : "disabled";
|
||||
await ctx.Reply($"Autoproxy is currently **{statusString}** for account <@{ctx.Author.Id}>.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AutoproxyEnableDisable(Context ctx, bool allow)
|
||||
{
|
||||
var statusString = allow ? "enabled" : "disabled";
|
||||
if (ctx.MessageContext.AllowAutoproxy == allow)
|
||||
{
|
||||
await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.Author.Id}>.");
|
||||
return;
|
||||
}
|
||||
var patch = new AccountPatch { AllowAutoproxy = allow };
|
||||
await _repo.UpdateAccount(ctx.Author.Id, patch);
|
||||
await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>.");
|
||||
}
|
||||
|
||||
private async Task UpdateAutoproxy(Context ctx, AutoproxyMode autoproxyMode, MemberId? autoproxyMember)
|
||||
{
|
||||
await _repo.GetSystemGuild(ctx.Guild.Id, ctx.System.Id);
|
||||
|
||||
var patch = new SystemGuildPatch { AutoproxyMode = autoproxyMode, AutoproxyMember = autoproxyMember };
|
||||
await _repo.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, patch);
|
||||
await UpdateAutoproxy(ctx, AutoproxyMode.Off, null);
|
||||
await ctx.Reply($"{Emojis.Success} Autoproxy turned off in this server.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AutoproxyLatch(Context ctx)
|
||||
{
|
||||
if (ctx.MessageContext.AutoproxyMode == AutoproxyMode.Latch)
|
||||
{
|
||||
await ctx.Reply($"{Emojis.Note} Autoproxy is already set to latch mode in this server. If you want to disable autoproxying, use `pk;autoproxy off`.");
|
||||
}
|
||||
else
|
||||
{
|
||||
await UpdateAutoproxy(ctx, AutoproxyMode.Latch, null);
|
||||
await ctx.Reply($"{Emojis.Success} Autoproxy set to latch mode in this server. Messages will now be autoproxied using the *last-proxied member* in this server.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AutoproxyFront(Context ctx)
|
||||
{
|
||||
if (ctx.MessageContext.AutoproxyMode == AutoproxyMode.Front)
|
||||
{
|
||||
await ctx.Reply($"{Emojis.Note} Autoproxy is already set to front mode in this server. If you want to disable autoproxying, use `pk;autoproxy off`.");
|
||||
}
|
||||
else
|
||||
{
|
||||
await UpdateAutoproxy(ctx, AutoproxyMode.Front, null);
|
||||
await ctx.Reply($"{Emojis.Success} Autoproxy set to front mode in this server. Messages will now be autoproxied using the *current first fronter*, if any.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AutoproxyMember(Context ctx, PKMember member)
|
||||
{
|
||||
ctx.CheckOwnMember(member);
|
||||
|
||||
await UpdateAutoproxy(ctx, AutoproxyMode.Member, member.Id);
|
||||
await ctx.Reply($"{Emojis.Success} Autoproxy set to **{member.NameFor(ctx)}** in this server.");
|
||||
}
|
||||
|
||||
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 EmbedBuilder()
|
||||
.Title($"Current autoproxy status (for {ctx.Guild.Name.EscapeMarkdown()})");
|
||||
|
||||
var fronters = ctx.MessageContext.LastSwitchMembers;
|
||||
var relevantMember = ctx.MessageContext.AutoproxyMode switch
|
||||
{
|
||||
AutoproxyMode.Front => fronters.Length > 0 ? await _repo.GetMember(fronters[0]) : null,
|
||||
AutoproxyMode.Member => await _repo.GetMember(ctx.MessageContext.AutoproxyMember.Value),
|
||||
_ => null
|
||||
};
|
||||
|
||||
switch (ctx.MessageContext.AutoproxyMode)
|
||||
{
|
||||
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.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.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.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.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.Field(new Embed.Field("\u200b", $"{Emojis.Note} Autoproxy is currently **disabled** for your account (<@{ctx.Author.Id}>). To enable it, use `pk;autoproxy account enable`."));
|
||||
|
||||
return eb.Build();
|
||||
}
|
||||
|
||||
public async Task AutoproxyTimeout(Context ctx)
|
||||
{
|
||||
if (!ctx.HasNext())
|
||||
{
|
||||
var timeout = ctx.System.LatchTimeout.HasValue
|
||||
? Duration.FromSeconds(ctx.System.LatchTimeout.Value)
|
||||
: (Duration?)null;
|
||||
|
||||
if (timeout == null)
|
||||
await ctx.Reply(
|
||||
$"You do not have a custom autoproxy timeout duration set. The default latch timeout duration is {ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}.");
|
||||
else if (timeout == Duration.Zero)
|
||||
await ctx.Reply(
|
||||
"Latch timeout is currently **disabled** for your system. Latch mode autoproxy will never time out.");
|
||||
else
|
||||
await ctx.Reply(
|
||||
$"The current latch timeout duration for your system is {timeout.Value.ToTimeSpan().Humanize(4)}.");
|
||||
return;
|
||||
}
|
||||
|
||||
Duration? newTimeout;
|
||||
var overflow = Duration.Zero;
|
||||
if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove"))
|
||||
{
|
||||
newTimeout = Duration.Zero;
|
||||
}
|
||||
else if (ctx.Match("reset", "default"))
|
||||
{
|
||||
newTimeout = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var timeoutStr = ctx.RemainderOrNull();
|
||||
var timeoutPeriod = DateUtils.ParsePeriod(timeoutStr);
|
||||
if (timeoutPeriod == null)
|
||||
throw new PKError($"Could not parse '{timeoutStr}' as a valid duration. Try using a syntax such as \"3h5m\" (i.e. 3 hours and 5 minutes).");
|
||||
if (timeoutPeriod.Value.TotalHours > 100000)
|
||||
{
|
||||
// sanity check to prevent seconds overflow if someone types in 999999999
|
||||
overflow = timeoutPeriod.Value;
|
||||
newTimeout = Duration.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
newTimeout = timeoutPeriod;
|
||||
}
|
||||
}
|
||||
|
||||
await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { LatchTimeout = (int?)newTimeout?.TotalSeconds });
|
||||
|
||||
if (newTimeout == null)
|
||||
await ctx.Reply($"{Emojis.Success} Latch timeout reset to default ({ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}).");
|
||||
else if (newTimeout == Duration.Zero && overflow != Duration.Zero)
|
||||
await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out. ({overflow.ToTimeSpan().Humanize(4)} is too long)");
|
||||
else if (newTimeout == Duration.Zero)
|
||||
await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out.");
|
||||
else
|
||||
await ctx.Reply($"{Emojis.Success} Latch timeout set to {newTimeout.Value!.ToTimeSpan().Humanize(4)}.");
|
||||
}
|
||||
|
||||
public async Task AutoproxyAccount(Context ctx)
|
||||
{
|
||||
// todo: this might be useful elsewhere, consider moving it to ctx.MatchToggle
|
||||
if (ctx.Match("enable", "on"))
|
||||
{
|
||||
await AutoproxyEnableDisable(ctx, true);
|
||||
}
|
||||
else if (ctx.Match("disable", "off"))
|
||||
{
|
||||
await AutoproxyEnableDisable(ctx, false);
|
||||
}
|
||||
else if (ctx.HasNext())
|
||||
{
|
||||
throw new PKSyntaxError("You must pass either \"on\" or \"off\".");
|
||||
}
|
||||
else
|
||||
{
|
||||
var statusString = ctx.MessageContext.AllowAutoproxy ? "enabled" : "disabled";
|
||||
await ctx.Reply($"Autoproxy is currently **{statusString}** for account <@{ctx.Author.Id}>.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AutoproxyEnableDisable(Context ctx, bool allow)
|
||||
{
|
||||
var statusString = allow ? "enabled" : "disabled";
|
||||
if (ctx.MessageContext.AllowAutoproxy == allow)
|
||||
{
|
||||
await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.Author.Id}>.");
|
||||
return;
|
||||
}
|
||||
|
||||
var patch = new AccountPatch { AllowAutoproxy = allow };
|
||||
await _repo.UpdateAccount(ctx.Author.Id, patch);
|
||||
await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>.");
|
||||
}
|
||||
|
||||
private async Task UpdateAutoproxy(Context ctx, AutoproxyMode autoproxyMode, MemberId? autoproxyMember)
|
||||
{
|
||||
await _repo.GetSystemGuild(ctx.Guild.Id, ctx.System.Id);
|
||||
|
||||
var patch = new SystemGuildPatch { AutoproxyMode = autoproxyMode, AutoproxyMember = autoproxyMember };
|
||||
await _repo.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, patch);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +1,61 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Myriad.Extensions;
|
||||
using Myriad.Types;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public static class ContextAvatarExt
|
||||
{
|
||||
public static class ContextAvatarExt
|
||||
public static async Task<ParsedImage?> MatchImage(this Context ctx)
|
||||
{
|
||||
public static async Task<ParsedImage?> MatchImage(this Context ctx)
|
||||
// If we have a user @mention/ID, use their avatar
|
||||
if (await ctx.MatchUser() is { } user)
|
||||
{
|
||||
// If we have a user @mention/ID, use their avatar
|
||||
if (await ctx.MatchUser() is { } user)
|
||||
{
|
||||
var url = user.AvatarUrl("png", 256);
|
||||
return new ParsedImage { Url = url, Source = AvatarSource.User, SourceUser = user };
|
||||
}
|
||||
|
||||
// If we have a positional argument, try to parse it as a URL
|
||||
var arg = ctx.RemainderOrNull();
|
||||
if (arg != null)
|
||||
{
|
||||
// Allow surrounding the URL with <angle brackets> to "de-embed"
|
||||
if (arg.StartsWith("<") && arg.EndsWith(">"))
|
||||
arg = arg.Substring(1, arg.Length - 2);
|
||||
|
||||
if (!Uri.TryCreate(arg, UriKind.Absolute, out var uri))
|
||||
throw Errors.InvalidUrl(arg);
|
||||
|
||||
if (uri.Scheme != "http" && uri.Scheme != "https")
|
||||
throw Errors.InvalidUrl(arg);
|
||||
|
||||
// ToString URL-decodes, which breaks URLs to spaces; AbsoluteUri doesn't
|
||||
return new ParsedImage { Url = uri.AbsoluteUri, Source = AvatarSource.Url };
|
||||
}
|
||||
|
||||
// If we have an attachment, use that
|
||||
if (ctx.Message.Attachments.FirstOrDefault() is { } attachment)
|
||||
{
|
||||
var url = attachment.ProxyUrl;
|
||||
return new ParsedImage { Url = url, Source = AvatarSource.Attachment };
|
||||
}
|
||||
|
||||
// We should only get here if there are no arguments (which would get parsed as URL + throw if error)
|
||||
// and if there are no attachments (which would have been caught just before)
|
||||
return null;
|
||||
var url = user.AvatarUrl("png", 256);
|
||||
return new ParsedImage { Url = url, Source = AvatarSource.User, SourceUser = user };
|
||||
}
|
||||
}
|
||||
|
||||
public struct ParsedImage
|
||||
{
|
||||
public string Url;
|
||||
public AvatarSource Source;
|
||||
public User? SourceUser;
|
||||
}
|
||||
// If we have a positional argument, try to parse it as a URL
|
||||
var arg = ctx.RemainderOrNull();
|
||||
if (arg != null)
|
||||
{
|
||||
// Allow surrounding the URL with <angle brackets> to "de-embed"
|
||||
if (arg.StartsWith("<") && arg.EndsWith(">"))
|
||||
arg = arg.Substring(1, arg.Length - 2);
|
||||
|
||||
public enum AvatarSource
|
||||
{
|
||||
Url,
|
||||
User,
|
||||
Attachment
|
||||
if (!Uri.TryCreate(arg, UriKind.Absolute, out var uri))
|
||||
throw Errors.InvalidUrl(arg);
|
||||
|
||||
if (uri.Scheme != "http" && uri.Scheme != "https")
|
||||
throw Errors.InvalidUrl(arg);
|
||||
|
||||
// ToString URL-decodes, which breaks URLs to spaces; AbsoluteUri doesn't
|
||||
return new ParsedImage { Url = uri.AbsoluteUri, Source = AvatarSource.Url };
|
||||
}
|
||||
|
||||
// If we have an attachment, use that
|
||||
if (ctx.Message.Attachments.FirstOrDefault() is { } attachment)
|
||||
{
|
||||
var url = attachment.ProxyUrl;
|
||||
return new ParsedImage { Url = url, Source = AvatarSource.Attachment };
|
||||
}
|
||||
|
||||
// We should only get here if there are no arguments (which would get parsed as URL + throw if error)
|
||||
// and if there are no attachments (which would have been caught just before)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public struct ParsedImage
|
||||
{
|
||||
public string Url;
|
||||
public AvatarSource Source;
|
||||
public User? SourceUser;
|
||||
}
|
||||
|
||||
public enum AvatarSource
|
||||
{
|
||||
Url,
|
||||
User,
|
||||
Attachment
|
||||
}
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Humanizer;
|
||||
|
||||
using Myriad.Builders;
|
||||
|
|
@ -13,262 +9,261 @@ using Myriad.Types;
|
|||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class Checks
|
||||
{
|
||||
public class Checks
|
||||
private readonly Bot _bot;
|
||||
private readonly BotConfig _botConfig;
|
||||
private readonly IDiscordCache _cache;
|
||||
private readonly IDatabase _db;
|
||||
private readonly ProxyMatcher _matcher;
|
||||
private readonly ProxyService _proxy;
|
||||
private readonly ModelRepository _repo;
|
||||
private readonly DiscordApiClient _rest;
|
||||
|
||||
private readonly PermissionSet[] requiredPermissions =
|
||||
{
|
||||
private readonly DiscordApiClient _rest;
|
||||
private readonly Bot _bot;
|
||||
private readonly IDiscordCache _cache;
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
private readonly BotConfig _botConfig;
|
||||
private readonly ProxyService _proxy;
|
||||
private readonly ProxyMatcher _matcher;
|
||||
PermissionSet.ViewChannel, PermissionSet.SendMessages, PermissionSet.AddReactions,
|
||||
PermissionSet.AttachFiles, PermissionSet.EmbedLinks, PermissionSet.ManageMessages,
|
||||
PermissionSet.ManageWebhooks
|
||||
};
|
||||
|
||||
public Checks(DiscordApiClient rest, Bot bot, IDiscordCache cache, IDatabase db, ModelRepository repo,
|
||||
BotConfig botConfig, ProxyService proxy, ProxyMatcher matcher)
|
||||
public Checks(DiscordApiClient rest, Bot bot, IDiscordCache cache, IDatabase db, ModelRepository repo,
|
||||
BotConfig botConfig, ProxyService proxy, ProxyMatcher matcher)
|
||||
{
|
||||
_rest = rest;
|
||||
_bot = bot;
|
||||
_cache = cache;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
_botConfig = botConfig;
|
||||
_proxy = proxy;
|
||||
_matcher = matcher;
|
||||
}
|
||||
|
||||
public async Task PermCheckGuild(Context ctx)
|
||||
{
|
||||
Guild guild;
|
||||
GuildMemberPartial senderGuildUser = null;
|
||||
|
||||
if (ctx.Guild != null && !ctx.HasNext())
|
||||
{
|
||||
_rest = rest;
|
||||
_bot = bot;
|
||||
_cache = cache;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
_botConfig = botConfig;
|
||||
_proxy = proxy;
|
||||
_matcher = matcher;
|
||||
guild = ctx.Guild;
|
||||
senderGuildUser = ctx.Member;
|
||||
}
|
||||
else
|
||||
{
|
||||
var guildIdStr = ctx.RemainderOrNull() ??
|
||||
throw new PKSyntaxError("You must pass a server ID or run this command in a server.");
|
||||
if (!ulong.TryParse(guildIdStr, out var guildId))
|
||||
throw new PKSyntaxError($"Could not parse {guildIdStr.AsCode()} as an ID.");
|
||||
|
||||
try
|
||||
{
|
||||
guild = await _rest.GetGuild(guildId);
|
||||
}
|
||||
catch (ForbiddenException)
|
||||
{
|
||||
throw Errors.GuildNotFound(guildId);
|
||||
}
|
||||
|
||||
if (guild != null)
|
||||
senderGuildUser = await _rest.GetGuildMember(guildId, ctx.Author.Id);
|
||||
if (guild == null || senderGuildUser == null)
|
||||
throw Errors.GuildNotFound(guildId);
|
||||
}
|
||||
|
||||
private readonly PermissionSet[] requiredPermissions = new[]
|
||||
{
|
||||
PermissionSet.ViewChannel,
|
||||
PermissionSet.SendMessages,
|
||||
PermissionSet.AddReactions,
|
||||
PermissionSet.AttachFiles,
|
||||
PermissionSet.EmbedLinks,
|
||||
PermissionSet.ManageMessages,
|
||||
PermissionSet.ManageWebhooks
|
||||
};
|
||||
|
||||
public async Task PermCheckGuild(Context ctx)
|
||||
// Loop through every channel and group them by sets of permissions missing
|
||||
var permissionsMissing = new Dictionary<ulong, List<Channel>>();
|
||||
var hiddenChannels = false;
|
||||
var missingEmojiPermissions = false;
|
||||
foreach (var channel in await _rest.GetGuildChannels(guild.Id))
|
||||
{
|
||||
Guild guild;
|
||||
GuildMemberPartial senderGuildUser = null;
|
||||
|
||||
if (ctx.Guild != null && !ctx.HasNext())
|
||||
{
|
||||
guild = ctx.Guild;
|
||||
senderGuildUser = ctx.Member;
|
||||
}
|
||||
else
|
||||
{
|
||||
var guildIdStr = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a server ID or run this command in a server.");
|
||||
if (!ulong.TryParse(guildIdStr, out var guildId))
|
||||
throw new PKSyntaxError($"Could not parse {guildIdStr.AsCode()} as an ID.");
|
||||
|
||||
try
|
||||
{
|
||||
guild = await _rest.GetGuild(guildId);
|
||||
}
|
||||
catch (Myriad.Rest.Exceptions.ForbiddenException)
|
||||
{
|
||||
throw Errors.GuildNotFound(guildId);
|
||||
}
|
||||
|
||||
if (guild != null)
|
||||
senderGuildUser = await _rest.GetGuildMember(guildId, ctx.Author.Id);
|
||||
if (guild == null || senderGuildUser == null)
|
||||
throw Errors.GuildNotFound(guildId);
|
||||
}
|
||||
|
||||
// Loop through every channel and group them by sets of permissions missing
|
||||
var permissionsMissing = new Dictionary<ulong, List<Channel>>();
|
||||
var hiddenChannels = false;
|
||||
var missingEmojiPermissions = false;
|
||||
foreach (var channel in await _rest.GetGuildChannels(guild.Id))
|
||||
{
|
||||
var botPermissions = await _cache.PermissionsIn(channel.Id);
|
||||
var webhookPermissions = await _cache.EveryonePermissions(channel);
|
||||
var userPermissions = PermissionExtensions.PermissionsFor(guild, channel, ctx.Author.Id, senderGuildUser);
|
||||
|
||||
if ((userPermissions & PermissionSet.ViewChannel) == 0)
|
||||
{
|
||||
// If the user can't see this channel, don't calculate permissions for it
|
||||
// (to prevent info-leaking, mostly)
|
||||
// Instead, show the user that some channels got ignored (so they don't get confused)
|
||||
hiddenChannels = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// We use a bitfield so we can set individual permission bits in the loop
|
||||
// TODO: Rewrite with proper bitfield math
|
||||
ulong missingPermissionField = 0;
|
||||
|
||||
foreach (var requiredPermission in requiredPermissions)
|
||||
if ((botPermissions & requiredPermission) == 0)
|
||||
missingPermissionField |= (ulong)requiredPermission;
|
||||
|
||||
if ((webhookPermissions & PermissionSet.UseExternalEmojis) == 0)
|
||||
{
|
||||
missingPermissionField |= (ulong)PermissionSet.UseExternalEmojis;
|
||||
missingEmojiPermissions = true;
|
||||
}
|
||||
|
||||
// If we're not missing any permissions, don't bother adding it to the dict
|
||||
// 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<Channel>());
|
||||
permissionsMissing[missingPermissionField].Add(channel);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the output embed
|
||||
var eb = new EmbedBuilder()
|
||||
.Title($"Permission check for **{guild.Name}**");
|
||||
|
||||
if (permissionsMissing.Count == 0)
|
||||
{
|
||||
eb.Description($"No errors found, all channels proxyable :)").Color(DiscordUtils.Green);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var (missingPermissionField, channels) in permissionsMissing)
|
||||
{
|
||||
// Each missing permission field can have multiple missing channels
|
||||
// so we extract them all and generate a comma-separated list
|
||||
var missingPermissionNames = ((PermissionSet)missingPermissionField).ToPermissionString();
|
||||
|
||||
var channelsList = string.Join("\n", channels
|
||||
.OrderBy(c => c.Position)
|
||||
.Select(c => $"#{c.Name}"));
|
||||
eb.Field(new($"Missing *{missingPermissionNames}*", channelsList.Truncate(1000)));
|
||||
eb.Color(DiscordUtils.Red);
|
||||
}
|
||||
}
|
||||
|
||||
var footer = "";
|
||||
if (hiddenChannels)
|
||||
footer += "Some channels were ignored as you do not have view access to them.";
|
||||
if (missingEmojiPermissions)
|
||||
{
|
||||
if (hiddenChannels) footer += " | ";
|
||||
footer += "Use External Emojis permissions must be granted to the @everyone role / Default Permissions.";
|
||||
}
|
||||
|
||||
if (footer.Length > 0)
|
||||
eb.Footer(new(footer));
|
||||
|
||||
// Send! :)
|
||||
await ctx.Reply(embed: eb.Build());
|
||||
}
|
||||
|
||||
public async Task PermCheckChannel(Context ctx)
|
||||
{
|
||||
if (!ctx.HasNext())
|
||||
throw new PKSyntaxError("You need to specify a channel.");
|
||||
|
||||
var error = "Channel not found or you do not have permissions to access it.";
|
||||
var channel = await ctx.MatchChannel();
|
||||
if (channel == null || channel.GuildId == null)
|
||||
throw new PKError(error);
|
||||
|
||||
if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
|
||||
throw new PKError(error);
|
||||
|
||||
var botPermissions = await _cache.PermissionsIn(channel.Id);
|
||||
var webhookPermissions = await _cache.EveryonePermissions(channel);
|
||||
var userPermissions =
|
||||
PermissionExtensions.PermissionsFor(guild, channel, ctx.Author.Id, senderGuildUser);
|
||||
|
||||
// We use a bitfield so we can set individual permission bits
|
||||
ulong missingPermissions = 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)
|
||||
// Instead, show the user that some channels got ignored (so they don't get confused)
|
||||
hiddenChannels = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// We use a bitfield so we can set individual permission bits in the loop
|
||||
// TODO: Rewrite with proper bitfield math
|
||||
ulong missingPermissionField = 0;
|
||||
|
||||
foreach (var requiredPermission in requiredPermissions)
|
||||
if ((botPermissions & requiredPermission) == 0)
|
||||
missingPermissions |= (ulong)requiredPermission;
|
||||
missingPermissionField |= (ulong)requiredPermission;
|
||||
|
||||
if ((webhookPermissions & PermissionSet.UseExternalEmojis) == 0)
|
||||
missingPermissions |= (ulong)PermissionSet.UseExternalEmojis;
|
||||
|
||||
// Generate the output embed
|
||||
var eb = new EmbedBuilder()
|
||||
.Title($"Permission check for **{channel.Name}**");
|
||||
|
||||
if (missingPermissions == 0)
|
||||
eb.Description("No issues found, channel is proxyable :)");
|
||||
else
|
||||
{
|
||||
var missing = "";
|
||||
|
||||
foreach (var permission in requiredPermissions)
|
||||
if (((ulong)permission & missingPermissions) == (ulong)permission)
|
||||
missing += $"\n- **{permission.ToPermissionString()}**";
|
||||
|
||||
if (((ulong)PermissionSet.UseExternalEmojis & missingPermissions) == (ulong)PermissionSet.UseExternalEmojis)
|
||||
missing += $"\n- **{PermissionSet.UseExternalEmojis.ToPermissionString()}**";
|
||||
|
||||
eb.Description($"Missing permissions:\n{missing}");
|
||||
missingPermissionField |= (ulong)PermissionSet.UseExternalEmojis;
|
||||
missingEmojiPermissions = true;
|
||||
}
|
||||
|
||||
await ctx.Reply(embed: eb.Build());
|
||||
// If we're not missing any permissions, don't bother adding it to the dict
|
||||
// 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<Channel>());
|
||||
permissionsMissing[missingPermissionField].Add(channel);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MessageProxyCheck(Context ctx)
|
||||
// Generate the output embed
|
||||
var eb = new EmbedBuilder()
|
||||
.Title($"Permission check for **{guild.Name}**");
|
||||
|
||||
if (permissionsMissing.Count == 0)
|
||||
eb.Description("No errors found, all channels proxyable :)").Color(DiscordUtils.Green);
|
||||
else
|
||||
foreach (var (missingPermissionField, channels) in permissionsMissing)
|
||||
{
|
||||
// Each missing permission field can have multiple missing channels
|
||||
// so we extract them all and generate a comma-separated list
|
||||
var missingPermissionNames = ((PermissionSet)missingPermissionField).ToPermissionString();
|
||||
|
||||
var channelsList = string.Join("\n", channels
|
||||
.OrderBy(c => c.Position)
|
||||
.Select(c => $"#{c.Name}"));
|
||||
eb.Field(new Embed.Field($"Missing *{missingPermissionNames}*", channelsList.Truncate(1000)));
|
||||
eb.Color(DiscordUtils.Red);
|
||||
}
|
||||
|
||||
var footer = "";
|
||||
if (hiddenChannels)
|
||||
footer += "Some channels were ignored as you do not have view access to them.";
|
||||
if (missingEmojiPermissions)
|
||||
{
|
||||
if (!ctx.HasNext() && ctx.Message.MessageReference == null)
|
||||
throw new PKSyntaxError("You need to specify a message.");
|
||||
if (hiddenChannels) footer += " | ";
|
||||
footer +=
|
||||
"Use External Emojis permissions must be granted to the @everyone role / Default Permissions.";
|
||||
}
|
||||
|
||||
var failedToGetMessage = "Could not find a valid message to check, was not able to fetch the message, or the message was not sent by you.";
|
||||
if (footer.Length > 0)
|
||||
eb.Footer(new Embed.EmbedFooter(footer));
|
||||
|
||||
var (messageId, channelId) = ctx.MatchMessage(false);
|
||||
if (messageId == null || channelId == null)
|
||||
throw new PKError(failedToGetMessage);
|
||||
// Send! :)
|
||||
await ctx.Reply(embed: eb.Build());
|
||||
}
|
||||
|
||||
var proxiedMsg = await _db.Execute(conn => _repo.GetMessage(conn, messageId.Value));
|
||||
if (proxiedMsg != null)
|
||||
{
|
||||
await ctx.Reply($"{Emojis.Success} This message was proxied successfully.");
|
||||
return;
|
||||
}
|
||||
public async Task PermCheckChannel(Context ctx)
|
||||
{
|
||||
if (!ctx.HasNext())
|
||||
throw new PKSyntaxError("You need to specify a channel.");
|
||||
|
||||
// get the message info
|
||||
var msg = await _rest.GetMessageOrNull(channelId.Value, messageId.Value);
|
||||
if (msg == null)
|
||||
throw new PKError(failedToGetMessage);
|
||||
var error = "Channel not found or you do not have permissions to access it.";
|
||||
var channel = await ctx.MatchChannel();
|
||||
if (channel == null || channel.GuildId == null)
|
||||
throw new PKError(error);
|
||||
|
||||
// if user is fetching a message in a different channel sent by someone else, throw a generic error message
|
||||
if (msg == null || (msg.Author.Id != ctx.Author.Id && msg.ChannelId != ctx.Channel.Id))
|
||||
throw new PKError(failedToGetMessage);
|
||||
if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
|
||||
throw new PKError(error);
|
||||
|
||||
if ((_botConfig.Prefixes ?? BotConfig.DefaultPrefixes).Any(p => msg.Content.StartsWith(p)))
|
||||
await ctx.Reply("This message starts with the bot's prefix, and was parsed as a command.");
|
||||
if (msg.Author.Bot)
|
||||
throw new PKError("You cannot check messages sent by a bot.");
|
||||
if (msg.WebhookId != null)
|
||||
throw new PKError("You cannot check messages sent by a webhook.");
|
||||
if (msg.Author.Id != ctx.Author.Id && !ctx.CheckBotAdmin())
|
||||
throw new PKError("You can only check your own messages.");
|
||||
var botPermissions = await _cache.PermissionsIn(channel.Id);
|
||||
var webhookPermissions = await _cache.EveryonePermissions(channel);
|
||||
|
||||
// get the channel info
|
||||
var channel = await _cache.GetChannel(channelId.Value);
|
||||
if (channel == null)
|
||||
throw new PKError("Unable to get the channel associated with this message.");
|
||||
// We use a bitfield so we can set individual permission bits
|
||||
ulong missingPermissions = 0;
|
||||
|
||||
// using channel.GuildId here since _rest.GetMessage() doesn't return the GuildId
|
||||
var context = await _repo.GetMessageContext(msg.Author.Id, channel.GuildId.Value, msg.ChannelId);
|
||||
var members = (await _repo.GetProxyMembers(msg.Author.Id, channel.GuildId.Value)).ToList();
|
||||
foreach (var requiredPermission in requiredPermissions)
|
||||
if ((botPermissions & requiredPermission) == 0)
|
||||
missingPermissions |= (ulong)requiredPermission;
|
||||
|
||||
// Run everything through the checks, catch the ProxyCheckFailedException, and reply with the error message.
|
||||
try
|
||||
{
|
||||
_proxy.ShouldProxy(channel, msg, context);
|
||||
_matcher.TryMatch(context, members, out var match, msg.Content, msg.Attachments.Length > 0, context.AllowAutoproxy);
|
||||
if ((webhookPermissions & PermissionSet.UseExternalEmojis) == 0)
|
||||
missingPermissions |= (ulong)PermissionSet.UseExternalEmojis;
|
||||
|
||||
await ctx.Reply("I'm not sure why this message was not proxied, sorry.");
|
||||
}
|
||||
catch (ProxyService.ProxyChecksFailedException e)
|
||||
{
|
||||
await ctx.Reply($"{e.Message}");
|
||||
}
|
||||
// Generate the output embed
|
||||
var eb = new EmbedBuilder()
|
||||
.Title($"Permission check for **{channel.Name}**");
|
||||
|
||||
if (missingPermissions == 0)
|
||||
{
|
||||
eb.Description("No issues found, channel is proxyable :)");
|
||||
}
|
||||
else
|
||||
{
|
||||
var missing = "";
|
||||
|
||||
foreach (var permission in requiredPermissions)
|
||||
if (((ulong)permission & missingPermissions) == (ulong)permission)
|
||||
missing += $"\n- **{permission.ToPermissionString()}**";
|
||||
|
||||
if (((ulong)PermissionSet.UseExternalEmojis & missingPermissions) ==
|
||||
(ulong)PermissionSet.UseExternalEmojis)
|
||||
missing += $"\n- **{PermissionSet.UseExternalEmojis.ToPermissionString()}**";
|
||||
|
||||
eb.Description($"Missing permissions:\n{missing}");
|
||||
}
|
||||
|
||||
await ctx.Reply(embed: eb.Build());
|
||||
}
|
||||
|
||||
public async Task MessageProxyCheck(Context ctx)
|
||||
{
|
||||
if (!ctx.HasNext() && ctx.Message.MessageReference == null)
|
||||
throw new PKSyntaxError("You need to specify a message.");
|
||||
|
||||
var failedToGetMessage =
|
||||
"Could not find a valid message to check, was not able to fetch the message, or the message was not sent by you.";
|
||||
|
||||
var (messageId, channelId) = ctx.MatchMessage(false);
|
||||
if (messageId == null || channelId == null)
|
||||
throw new PKError(failedToGetMessage);
|
||||
|
||||
var proxiedMsg = await _db.Execute(conn => _repo.GetMessage(conn, messageId.Value));
|
||||
if (proxiedMsg != null)
|
||||
{
|
||||
await ctx.Reply($"{Emojis.Success} This message was proxied successfully.");
|
||||
return;
|
||||
}
|
||||
|
||||
// get the message info
|
||||
var msg = await _rest.GetMessageOrNull(channelId.Value, messageId.Value);
|
||||
if (msg == null)
|
||||
throw new PKError(failedToGetMessage);
|
||||
|
||||
// if user is fetching a message in a different channel sent by someone else, throw a generic error message
|
||||
if (msg == null || msg.Author.Id != ctx.Author.Id && msg.ChannelId != ctx.Channel.Id)
|
||||
throw new PKError(failedToGetMessage);
|
||||
|
||||
if ((_botConfig.Prefixes ?? BotConfig.DefaultPrefixes).Any(p => msg.Content.StartsWith(p)))
|
||||
await ctx.Reply("This message starts with the bot's prefix, and was parsed as a command.");
|
||||
if (msg.Author.Bot)
|
||||
throw new PKError("You cannot check messages sent by a bot.");
|
||||
if (msg.WebhookId != null)
|
||||
throw new PKError("You cannot check messages sent by a webhook.");
|
||||
if (msg.Author.Id != ctx.Author.Id && !ctx.CheckBotAdmin())
|
||||
throw new PKError("You can only check your own messages.");
|
||||
|
||||
// get the channel info
|
||||
var channel = await _cache.GetChannel(channelId.Value);
|
||||
if (channel == null)
|
||||
throw new PKError("Unable to get the channel associated with this message.");
|
||||
|
||||
// using channel.GuildId here since _rest.GetMessage() doesn't return the GuildId
|
||||
var context = await _repo.GetMessageContext(msg.Author.Id, channel.GuildId.Value, msg.ChannelId);
|
||||
var members = (await _repo.GetProxyMembers(msg.Author.Id, channel.GuildId.Value)).ToList();
|
||||
|
||||
// Run everything through the checks, catch the ProxyCheckFailedException, and reply with the error message.
|
||||
try
|
||||
{
|
||||
_proxy.ShouldProxy(channel, msg, context);
|
||||
_matcher.TryMatch(context, members, out var match, msg.Content, msg.Attachments.Length > 0,
|
||||
context.AllowAutoproxy);
|
||||
|
||||
await ctx.Reply("I'm not sure why this message was not proxied, sorry.");
|
||||
}
|
||||
catch (ProxyService.ProxyChecksFailedException e)
|
||||
{
|
||||
await ctx.Reply($"{e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,34 +1,47 @@
|
|||
using System.Threading.Tasks;
|
||||
|
||||
using Myriad.Builders;
|
||||
using Myriad.Types;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
{
|
||||
public class Fun
|
||||
{
|
||||
public Task Mn(Context ctx) => ctx.Reply("Gotta catch 'em all!");
|
||||
public Task Fire(Context ctx) => ctx.Reply("*A giant lightning bolt promptly erupts into a pillar of fire as it hits your opponent.*");
|
||||
public Task Thunder(Context ctx) => ctx.Reply("*A giant ball of lightning is conjured and fired directly at your opponent, vanquishing them.*");
|
||||
public Task Freeze(Context ctx) => ctx.Reply("*A giant crystal ball of ice is charged and hurled toward your opponent, bursting open and freezing them solid on contact.*");
|
||||
public Task Starstorm(Context ctx) => ctx.Reply("*Vibrant colours burst forth from the sky as meteors rain down upon your opponent.*");
|
||||
public Task Flash(Context ctx) => ctx.Reply("*A ball of green light appears above your head and flies towards your enemy, exploding on contact.*");
|
||||
public Task Error(Context ctx)
|
||||
{
|
||||
if (ctx.Match("message"))
|
||||
return ctx.Reply($"> **Error code:** `50f3c7b439d111ecab2023a5431fffbd`", embed: new EmbedBuilder()
|
||||
.Color(0xE74C3C)
|
||||
.Title("Internal error occurred")
|
||||
.Description("For support, please send the error code above in **#bug-reports-and-errors** on **[the support server *(click to join)*](https://discord.gg/PczBt78)** with a description of what you were doing at the time.")
|
||||
.Footer(new("50f3c7b439d111ecab2023a5431fffbd"))
|
||||
.Timestamp(SystemClock.Instance.GetCurrentInstant().ToDateTimeOffset().ToString("O"))
|
||||
.Build()
|
||||
);
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
return ctx.Reply($"{Emojis.Error} Unknown command {"error".AsCode()}. For a list of possible commands, see <https://pluralkit.me/commands>.");
|
||||
}
|
||||
public class Fun
|
||||
{
|
||||
public Task Mn(Context ctx) => ctx.Reply("Gotta catch 'em all!");
|
||||
|
||||
public Task Fire(Context ctx) =>
|
||||
ctx.Reply("*A giant lightning bolt promptly erupts into a pillar of fire as it hits your opponent.*");
|
||||
|
||||
public Task Thunder(Context ctx) =>
|
||||
ctx.Reply("*A giant ball of lightning is conjured and fired directly at your opponent, vanquishing them.*");
|
||||
|
||||
public Task Freeze(Context ctx) =>
|
||||
ctx.Reply(
|
||||
"*A giant crystal ball of ice is charged and hurled toward your opponent, bursting open and freezing them solid on contact.*");
|
||||
|
||||
public Task Starstorm(Context ctx) =>
|
||||
ctx.Reply("*Vibrant colours burst forth from the sky as meteors rain down upon your opponent.*");
|
||||
|
||||
public Task Flash(Context ctx) =>
|
||||
ctx.Reply(
|
||||
"*A ball of green light appears above your head and flies towards your enemy, exploding on contact.*");
|
||||
|
||||
public Task Error(Context ctx)
|
||||
{
|
||||
if (ctx.Match("message"))
|
||||
return ctx.Reply("> **Error code:** `50f3c7b439d111ecab2023a5431fffbd`", new EmbedBuilder()
|
||||
.Color(0xE74C3C)
|
||||
.Title("Internal error occurred")
|
||||
.Description(
|
||||
"For support, please send the error code above in **#bug-reports-and-errors** on **[the support server *(click to join)*](https://discord.gg/PczBt78)** with a description of what you were doing at the time.")
|
||||
.Footer(new Embed.EmbedFooter("50f3c7b439d111ecab2023a5431fffbd"))
|
||||
.Timestamp(SystemClock.Instance.GetCurrentInstant().ToDateTimeOffset().ToString("O"))
|
||||
.Build()
|
||||
);
|
||||
|
||||
return ctx.Reply(
|
||||
$"{Emojis.Error} Unknown command {"error".AsCode()}. For a list of possible commands, see <https://pluralkit.me/commands>.");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,32 +1,39 @@
|
|||
using System.Threading.Tasks;
|
||||
|
||||
using Myriad.Builders;
|
||||
using Myriad.Types;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
{
|
||||
public class Help
|
||||
{
|
||||
public async Task HelpRoot(Context ctx)
|
||||
{
|
||||
await ctx.Reply(embed: new EmbedBuilder()
|
||||
.Title("PluralKit")
|
||||
.Description("PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.")
|
||||
.Field(new("What is this for? What are systems?", "This bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks. This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account."))
|
||||
.Field(new("Why are people's names saying [BOT] next to them?", "These people are not actually bots, this is just a Discord limitation. See [the documentation](https://pluralkit.me/guide#proxying) for an in-depth explanation."))
|
||||
.Field(new("How do I get started?", "To get started using PluralKit, try running the following commands (of course replacing the relevant names with your own):\n**1**. `pk;system new` - Create a system (if you haven't already)\n**2**. `pk;member add John` - Add a new member to your system\n**3**. `pk;member John proxy [text]` - Set up [square brackets] as proxy tags\n**4**. You're done! You can now type [a message in brackets] and it'll be proxied appropriately.\n**5**. Optionally, you may set an avatar from the URL of an image with `pk;member John avatar [link to image]`, or from a file by typing `pk;member John avatar` and sending the message with an attached image.\n\nSee [the Getting Started guide](https://pluralkit.me/start) for more information."))
|
||||
.Field(new("Useful tips", $"React with {Emojis.Error} on a proxied message to delete it (only if you sent it!)\nReact with {Emojis.RedQuestion} on a proxied message to look up information about it (like who sent it)\nReact with {Emojis.Bell} on a proxied message to \"ping\" the sender\nType **`pk;invite`** to get a link to invite this bot to your own server!"))
|
||||
.Field(new("More information", "For a full list of commands, see [the command list](https://pluralkit.me/commands).\nFor a more in-depth explanation of message proxying, see [the documentation](https://pluralkit.me/guide#proxying).\nIf you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there."))
|
||||
.Field(new("Support server", "We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78"))
|
||||
.Footer(new($"By @Ske#6201 | Myriad by @Layl#8888 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/"))
|
||||
.Color(DiscordUtils.Blue)
|
||||
.Build());
|
||||
}
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public async Task Explain(Context ctx)
|
||||
{
|
||||
await ctx.Reply("> **About PluralKit**\nPluralKit detects messages enclosed in specific tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using Discord webhooks.\n\nThis is useful for multiple people sharing one body (aka. *systems*), people who wish to role-play as different characters without having multiple Discord accounts, or anyone else who may want to post messages under a different identity from the same Discord account.\n\nDue to Discord limitations, these messages will show up with the `[BOT]` tag - however, they are not bots.");
|
||||
}
|
||||
public class Help
|
||||
{
|
||||
public async Task HelpRoot(Context ctx)
|
||||
{
|
||||
await ctx.Reply(embed: new EmbedBuilder()
|
||||
.Title("PluralKit")
|
||||
.Description(
|
||||
"PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.")
|
||||
.Field(new Embed.Field("What is this for? What are systems?",
|
||||
"This bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks. This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account."))
|
||||
.Field(new Embed.Field("Why are people's names saying [BOT] next to them?",
|
||||
"These people are not actually bots, this is just a Discord limitation. See [the documentation](https://pluralkit.me/guide#proxying) for an in-depth explanation."))
|
||||
.Field(new Embed.Field("How do I get started?",
|
||||
"To get started using PluralKit, try running the following commands (of course replacing the relevant names with your own):\n**1**. `pk;system new` - Create a system (if you haven't already)\n**2**. `pk;member add John` - Add a new member to your system\n**3**. `pk;member John proxy [text]` - Set up [square brackets] as proxy tags\n**4**. You're done! You can now type [a message in brackets] and it'll be proxied appropriately.\n**5**. Optionally, you may set an avatar from the URL of an image with `pk;member John avatar [link to image]`, or from a file by typing `pk;member John avatar` and sending the message with an attached image.\n\nSee [the Getting Started guide](https://pluralkit.me/start) for more information."))
|
||||
.Field(new Embed.Field("Useful tips",
|
||||
$"React with {Emojis.Error} on a proxied message to delete it (only if you sent it!)\nReact with {Emojis.RedQuestion} on a proxied message to look up information about it (like who sent it)\nReact with {Emojis.Bell} on a proxied message to \"ping\" the sender\nType **`pk;invite`** to get a link to invite this bot to your own server!"))
|
||||
.Field(new Embed.Field("More information",
|
||||
"For a full list of commands, see [the command list](https://pluralkit.me/commands).\nFor a more in-depth explanation of message proxying, see [the documentation](https://pluralkit.me/guide#proxying).\nIf you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there."))
|
||||
.Field(new Embed.Field("Support server",
|
||||
"We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78"))
|
||||
.Footer(new Embed.EmbedFooter(
|
||||
"By @Ske#6201 | Myriad by @Layl#8888 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/"))
|
||||
.Color(DiscordUtils.Blue)
|
||||
.Build());
|
||||
}
|
||||
|
||||
public async Task Explain(Context ctx)
|
||||
{
|
||||
await ctx.Reply(
|
||||
"> **About PluralKit**\nPluralKit detects messages enclosed in specific tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using Discord webhooks.\n\nThis is useful for multiple people sharing one body (aka. *systems*), people who wish to role-play as different characters without having multiple Discord accounts, or anyone else who may want to post messages under a different identity from the same Discord account.\n\nDue to Discord limitations, these messages will show up with the `[BOT]` tag - however, they are not bots.");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,4 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Myriad.Extensions;
|
||||
using Myriad.Rest.Exceptions;
|
||||
|
|
@ -12,120 +7,125 @@ using Myriad.Rest.Types.Requests;
|
|||
using Myriad.Types;
|
||||
|
||||
using Newtonsoft.Json;
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class ImportExport
|
||||
{
|
||||
public class ImportExport
|
||||
private readonly HttpClient _client;
|
||||
private readonly DataFileService _dataFiles;
|
||||
|
||||
private readonly JsonSerializerSettings _settings = new()
|
||||
{
|
||||
private readonly DataFileService _dataFiles;
|
||||
private readonly HttpClient _client;
|
||||
private readonly JsonSerializerSettings _settings = new()
|
||||
// Otherwise it'll mess up/reformat the ISO strings for ???some??? reason >.>
|
||||
DateParseHandling = DateParseHandling.None
|
||||
};
|
||||
|
||||
public ImportExport(DataFileService dataFiles, HttpClient client)
|
||||
{
|
||||
_dataFiles = dataFiles;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public async Task Import(Context ctx)
|
||||
{
|
||||
var url = ctx.RemainderOrNull() ?? ctx.Message.Attachments.FirstOrDefault()?.Url;
|
||||
if (url == null) throw Errors.NoImportFilePassed;
|
||||
|
||||
await ctx.BusyIndicator(async () =>
|
||||
{
|
||||
// Otherwise it'll mess up/reformat the ISO strings for ???some??? reason >.>
|
||||
DateParseHandling = DateParseHandling.None
|
||||
};
|
||||
|
||||
public ImportExport(DataFileService dataFiles, HttpClient client)
|
||||
{
|
||||
_dataFiles = dataFiles;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public async Task Import(Context ctx)
|
||||
{
|
||||
var url = ctx.RemainderOrNull() ?? ctx.Message.Attachments.FirstOrDefault()?.Url;
|
||||
if (url == null) throw Errors.NoImportFilePassed;
|
||||
|
||||
await ctx.BusyIndicator(async () =>
|
||||
{
|
||||
JObject data;
|
||||
try
|
||||
{
|
||||
var response = await _client.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw Errors.InvalidImportFile;
|
||||
data = JsonConvert.DeserializeObject<JObject>(await response.Content.ReadAsStringAsync(), _settings);
|
||||
if (data == null)
|
||||
throw Errors.InvalidImportFile;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Invalid URL throws this, we just error back out
|
||||
throw Errors.InvalidImportFile;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw Errors.InvalidImportFile;
|
||||
}
|
||||
|
||||
async Task ConfirmImport(string message)
|
||||
{
|
||||
var msg = $"{message}\n\nDo you want to proceed with the import?";
|
||||
if (!await ctx.PromptYesNo(msg, "Proceed"))
|
||||
throw Errors.ImportCancelled;
|
||||
}
|
||||
|
||||
if (data.ContainsKey("accounts")
|
||||
&& data.Value<JArray>("accounts").Type != JTokenType.Null
|
||||
&& data.Value<JArray>("accounts").Contains((JToken)ctx.Author.Id.ToString()))
|
||||
{
|
||||
var msg = $"{Emojis.Warn} You seem to importing a system profile belonging to another account. Are you sure you want to proceed?";
|
||||
if (!await ctx.PromptYesNo(msg, "Import")) throw Errors.ImportCancelled;
|
||||
}
|
||||
|
||||
var result = await _dataFiles.ImportSystem(ctx.Author.Id, ctx.System, data, ConfirmImport);
|
||||
if (!result.Success)
|
||||
if (result.Message == null)
|
||||
throw Errors.InvalidImportFile;
|
||||
else
|
||||
await ctx.Reply($"{Emojis.Error} The provided system profile could not be imported: {result.Message}");
|
||||
else if (ctx.System == null)
|
||||
// We didn't have a system prior to importing, so give them the new system's ID
|
||||
await ctx.Reply($"{Emojis.Success} PluralKit has created a system for you based on the given file. Your system ID is `{result.CreatedSystem}`. Type `pk;system` for more information.");
|
||||
else
|
||||
// We already had a system, so show them what changed
|
||||
await ctx.Reply($"{Emojis.Success} Updated {result.Modified} members, created {result.Added} members. Type `pk;system list` to check!");
|
||||
});
|
||||
}
|
||||
|
||||
public async Task Export(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
var json = await ctx.BusyIndicator(async () =>
|
||||
{
|
||||
// Make the actual data file
|
||||
var data = await _dataFiles.ExportSystem(ctx.System);
|
||||
return JsonConvert.SerializeObject(data, Formatting.None);
|
||||
});
|
||||
|
||||
|
||||
// Send it as a Discord attachment *in DMs*
|
||||
var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
JObject data;
|
||||
try
|
||||
{
|
||||
var dm = await ctx.Cache.GetOrCreateDmChannel(ctx.Rest, ctx.Author.Id);
|
||||
|
||||
var msg = await ctx.Rest.CreateMessage(dm.Id,
|
||||
new MessageRequest { Content = $"{Emojis.Success} Here you go!" },
|
||||
new[] { new MultipartFile("system.json", stream, null) });
|
||||
await ctx.Rest.CreateMessage(dm.Id, new MessageRequest { Content = $"<{msg.Attachments[0].Url}>" });
|
||||
|
||||
// If the original message wasn't posted in DMs, send a public reminder
|
||||
if (ctx.Channel.Type != Channel.ChannelType.Dm)
|
||||
await ctx.Reply($"{Emojis.Success} Check your DMs!");
|
||||
var response = await _client.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw Errors.InvalidImportFile;
|
||||
data = JsonConvert.DeserializeObject<JObject>(
|
||||
await response.Content.ReadAsStringAsync(),
|
||||
_settings
|
||||
);
|
||||
if (data == null)
|
||||
throw Errors.InvalidImportFile;
|
||||
}
|
||||
catch (ForbiddenException)
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// If user has DMs closed, tell 'em to open them
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Error} Could not send the data file in your DMs. Do you have DMs closed?");
|
||||
// Invalid URL throws this, we just error back out
|
||||
throw Errors.InvalidImportFile;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw Errors.InvalidImportFile;
|
||||
}
|
||||
|
||||
async Task ConfirmImport(string message)
|
||||
{
|
||||
var msg = $"{message}\n\nDo you want to proceed with the import?";
|
||||
if (!await ctx.PromptYesNo(msg, "Proceed"))
|
||||
throw Errors.ImportCancelled;
|
||||
}
|
||||
|
||||
if (data.ContainsKey("accounts")
|
||||
&& data.Value<JArray>("accounts").Type != JTokenType.Null
|
||||
&& data.Value<JArray>("accounts").Contains(ctx.Author.Id.ToString()))
|
||||
{
|
||||
var msg = $"{Emojis.Warn} You seem to importing a system profile belonging to another account. Are you sure you want to proceed?";
|
||||
if (!await ctx.PromptYesNo(msg, "Import")) throw Errors.ImportCancelled;
|
||||
}
|
||||
|
||||
var result = await _dataFiles.ImportSystem(ctx.Author.Id, ctx.System, data, ConfirmImport);
|
||||
if (!result.Success)
|
||||
if (result.Message == null)
|
||||
throw Errors.InvalidImportFile;
|
||||
else
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Error} The provided system profile could not be imported: {result.Message}");
|
||||
else if (ctx.System == null)
|
||||
// We didn't have a system prior to importing, so give them the new system's ID
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Success} PluralKit has created a system for you based on the given file. Your system ID is `{result.CreatedSystem}`. Type `pk;system` for more information.");
|
||||
else
|
||||
// We already had a system, so show them what changed
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Success} Updated {result.Modified} members, created {result.Added} members. Type `pk;system list` to check!");
|
||||
});
|
||||
}
|
||||
|
||||
public async Task Export(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
var json = await ctx.BusyIndicator(async () =>
|
||||
{
|
||||
// Make the actual data file
|
||||
var data = await _dataFiles.ExportSystem(ctx.System);
|
||||
return JsonConvert.SerializeObject(data, Formatting.None);
|
||||
});
|
||||
|
||||
|
||||
// Send it as a Discord attachment *in DMs*
|
||||
var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
try
|
||||
{
|
||||
var dm = await ctx.Cache.GetOrCreateDmChannel(ctx.Rest, ctx.Author.Id);
|
||||
|
||||
var msg = await ctx.Rest.CreateMessage(dm.Id,
|
||||
new MessageRequest { Content = $"{Emojis.Success} Here you go!" },
|
||||
new[] { new MultipartFile("system.json", stream, null) });
|
||||
await ctx.Rest.CreateMessage(dm.Id, new MessageRequest { Content = $"<{msg.Attachments[0].Url}>" });
|
||||
|
||||
// If the original message wasn't posted in DMs, send a public reminder
|
||||
if (ctx.Channel.Type != Channel.ChannelType.Dm)
|
||||
await ctx.Reply($"{Emojis.Success} Check your DMs!");
|
||||
}
|
||||
catch (ForbiddenException)
|
||||
{
|
||||
// If user has DMs closed, tell 'em to open them
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Error} Could not send the data file in your DMs. Do you have DMs closed?");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,229 +1,252 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Humanizer;
|
||||
|
||||
using Myriad.Builders;
|
||||
using Myriad.Types;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public static class ContextListExt
|
||||
{
|
||||
public static class ContextListExt
|
||||
public static MemberListOptions ParseMemberListOptions(this Context ctx, LookupContext lookupCtx)
|
||||
{
|
||||
public static MemberListOptions ParseMemberListOptions(this Context ctx, LookupContext lookupCtx)
|
||||
var p = new MemberListOptions();
|
||||
|
||||
// Short or long list? (parse this first, as it can potentially take a positional argument)
|
||||
var isFull = ctx.Match("f", "full", "big", "details", "long") || ctx.MatchFlag("f", "full");
|
||||
p.Type = isFull ? ListType.Long : ListType.Short;
|
||||
|
||||
// Search query
|
||||
if (ctx.HasNext())
|
||||
p.Search = ctx.RemainderOrNull();
|
||||
|
||||
// Include description in search?
|
||||
if (ctx.MatchFlag(
|
||||
"search-description",
|
||||
"filter-description",
|
||||
"in-description",
|
||||
"sd",
|
||||
"description",
|
||||
"desc"
|
||||
))
|
||||
p.SearchDescription = true;
|
||||
|
||||
// Sort property (default is by name, but adding a flag anyway, 'cause why not)
|
||||
if (ctx.MatchFlag("by-name", "bn")) p.SortProperty = SortProperty.Name;
|
||||
if (ctx.MatchFlag("by-display-name", "bdn")) p.SortProperty = SortProperty.DisplayName;
|
||||
if (ctx.MatchFlag("by-id", "bid")) p.SortProperty = SortProperty.Hid;
|
||||
if (ctx.MatchFlag("by-message-count", "bmc")) p.SortProperty = SortProperty.MessageCount;
|
||||
if (ctx.MatchFlag("by-created", "bc", "bcd")) p.SortProperty = SortProperty.CreationDate;
|
||||
if (ctx.MatchFlag("by-last-fronted", "by-last-front", "by-last-switch", "blf", "bls"))
|
||||
p.SortProperty = SortProperty.LastSwitch;
|
||||
if (ctx.MatchFlag("by-last-message", "blm", "blp")) p.SortProperty = SortProperty.LastMessage;
|
||||
if (ctx.MatchFlag("by-birthday", "by-birthdate", "bbd")) p.SortProperty = SortProperty.Birthdate;
|
||||
if (ctx.MatchFlag("random")) p.SortProperty = SortProperty.Random;
|
||||
|
||||
// Sort reverse?
|
||||
if (ctx.MatchFlag("r", "rev", "reverse"))
|
||||
p.Reverse = true;
|
||||
|
||||
// Privacy filter (default is public only)
|
||||
if (ctx.MatchFlag("a", "all")) p.PrivacyFilter = null;
|
||||
if (ctx.MatchFlag("private-only", "private", "priv")) p.PrivacyFilter = PrivacyLevel.Private;
|
||||
if (ctx.MatchFlag("public-only", "public", "pub")) p.PrivacyFilter = PrivacyLevel.Public;
|
||||
|
||||
// PERM CHECK: If we're trying to access non-public members of another system, error
|
||||
if (p.PrivacyFilter != PrivacyLevel.Public && lookupCtx != LookupContext.ByOwner)
|
||||
// TODO: should this just return null instead of throwing or something? >.>
|
||||
throw new PKError("You cannot look up private members of another system.");
|
||||
|
||||
// Additional fields to include in the search results
|
||||
if (ctx.MatchFlag("with-last-switch", "with-last-fronted", "with-last-front", "wls", "wlf"))
|
||||
p.IncludeLastSwitch = true;
|
||||
if (ctx.MatchFlag("with-last-message", "with-last-proxy", "wlm", "wlp"))
|
||||
throw new PKError("Sorting by last message is temporarily disabled due to database issues, sorry.");
|
||||
// p.IncludeLastMessage = true;
|
||||
if (ctx.MatchFlag("with-message-count", "wmc"))
|
||||
p.IncludeMessageCount = true;
|
||||
if (ctx.MatchFlag("with-created", "wc"))
|
||||
p.IncludeCreated = true;
|
||||
if (ctx.MatchFlag("with-avatar", "with-image", "wa", "wi", "ia", "ii", "img"))
|
||||
p.IncludeAvatar = true;
|
||||
if (ctx.MatchFlag("with-pronouns", "wp"))
|
||||
p.IncludePronouns = true;
|
||||
|
||||
// Always show the sort property, too
|
||||
if (p.SortProperty == SortProperty.LastSwitch) p.IncludeLastSwitch = true;
|
||||
if (p.SortProperty == SortProperty.LastMessage) p.IncludeLastMessage = true;
|
||||
if (p.SortProperty == SortProperty.MessageCount) p.IncludeMessageCount = true;
|
||||
if (p.SortProperty == SortProperty.CreationDate) p.IncludeCreated = true;
|
||||
|
||||
// Done!
|
||||
return p;
|
||||
}
|
||||
|
||||
public static async Task RenderMemberList(this Context ctx, LookupContext lookupCtx, IDatabase db,
|
||||
SystemId system, string embedTitle, string color, MemberListOptions opts)
|
||||
{
|
||||
// We take an IDatabase instead of a IPKConnection so we don't keep the handle open for the entire runtime
|
||||
// We wanna release it as soon as the member list is actually *fetched*, instead of potentially minutes later (paginate timeout)
|
||||
var members = (await db.Execute(conn => conn.QueryMemberList(system, opts.ToQueryOptions())))
|
||||
.SortByMemberListOptions(opts, lookupCtx)
|
||||
.ToList();
|
||||
|
||||
var itemsPerPage = opts.Type == ListType.Short ? 25 : 5;
|
||||
await ctx.Paginate(members.ToAsyncEnumerable(), members.Count, itemsPerPage, embedTitle, color, Renderer);
|
||||
|
||||
// Base renderer, dispatches based on type
|
||||
Task Renderer(EmbedBuilder eb, IEnumerable<ListedMember> page)
|
||||
{
|
||||
var p = new MemberListOptions();
|
||||
// Add a global footer with the filter/sort string + result count
|
||||
eb.Footer(new Embed.EmbedFooter($"{opts.CreateFilterString()}. {"result".ToQuantity(members.Count)}."));
|
||||
|
||||
// Short or long list? (parse this first, as it can potentially take a positional argument)
|
||||
var isFull = ctx.Match("f", "full", "big", "details", "long") || ctx.MatchFlag("f", "full");
|
||||
p.Type = isFull ? ListType.Long : ListType.Short;
|
||||
// Then call the specific renderers
|
||||
if (opts.Type == ListType.Short)
|
||||
ShortRenderer(eb, page);
|
||||
else
|
||||
LongRenderer(eb, page);
|
||||
|
||||
// Search query
|
||||
if (ctx.HasNext())
|
||||
p.Search = ctx.RemainderOrNull();
|
||||
|
||||
// Include description in search?
|
||||
if (ctx.MatchFlag("search-description", "filter-description", "in-description", "sd", "description", "desc"))
|
||||
p.SearchDescription = true;
|
||||
|
||||
// Sort property (default is by name, but adding a flag anyway, 'cause why not)
|
||||
if (ctx.MatchFlag("by-name", "bn")) p.SortProperty = SortProperty.Name;
|
||||
if (ctx.MatchFlag("by-display-name", "bdn")) p.SortProperty = SortProperty.DisplayName;
|
||||
if (ctx.MatchFlag("by-id", "bid")) p.SortProperty = SortProperty.Hid;
|
||||
if (ctx.MatchFlag("by-message-count", "bmc")) p.SortProperty = SortProperty.MessageCount;
|
||||
if (ctx.MatchFlag("by-created", "bc", "bcd")) p.SortProperty = SortProperty.CreationDate;
|
||||
if (ctx.MatchFlag("by-last-fronted", "by-last-front", "by-last-switch", "blf", "bls")) p.SortProperty = SortProperty.LastSwitch;
|
||||
if (ctx.MatchFlag("by-last-message", "blm", "blp")) p.SortProperty = SortProperty.LastMessage;
|
||||
if (ctx.MatchFlag("by-birthday", "by-birthdate", "bbd")) p.SortProperty = SortProperty.Birthdate;
|
||||
if (ctx.MatchFlag("random")) p.SortProperty = SortProperty.Random;
|
||||
|
||||
// Sort reverse?
|
||||
if (ctx.MatchFlag("r", "rev", "reverse"))
|
||||
p.Reverse = true;
|
||||
|
||||
// Privacy filter (default is public only)
|
||||
if (ctx.MatchFlag("a", "all")) p.PrivacyFilter = null;
|
||||
if (ctx.MatchFlag("private-only", "private", "priv")) p.PrivacyFilter = PrivacyLevel.Private;
|
||||
if (ctx.MatchFlag("public-only", "public", "pub")) p.PrivacyFilter = PrivacyLevel.Public;
|
||||
|
||||
// PERM CHECK: If we're trying to access non-public members of another system, error
|
||||
if (p.PrivacyFilter != PrivacyLevel.Public && lookupCtx != LookupContext.ByOwner)
|
||||
// TODO: should this just return null instead of throwing or something? >.>
|
||||
throw new PKError("You cannot look up private members of another system.");
|
||||
|
||||
// Additional fields to include in the search results
|
||||
if (ctx.MatchFlag("with-last-switch", "with-last-fronted", "with-last-front", "wls", "wlf"))
|
||||
p.IncludeLastSwitch = true;
|
||||
if (ctx.MatchFlag("with-last-message", "with-last-proxy", "wlm", "wlp"))
|
||||
throw new PKError("Sorting by last message is temporarily disabled due to database issues, sorry.");
|
||||
// p.IncludeLastMessage = true;
|
||||
if (ctx.MatchFlag("with-message-count", "wmc"))
|
||||
p.IncludeMessageCount = true;
|
||||
if (ctx.MatchFlag("with-created", "wc"))
|
||||
p.IncludeCreated = true;
|
||||
if (ctx.MatchFlag("with-avatar", "with-image", "wa", "wi", "ia", "ii", "img"))
|
||||
p.IncludeAvatar = true;
|
||||
if (ctx.MatchFlag("with-pronouns", "wp"))
|
||||
p.IncludePronouns = true;
|
||||
|
||||
// Always show the sort property, too
|
||||
if (p.SortProperty == SortProperty.LastSwitch) p.IncludeLastSwitch = true;
|
||||
if (p.SortProperty == SortProperty.LastMessage) p.IncludeLastMessage = true;
|
||||
if (p.SortProperty == SortProperty.MessageCount) p.IncludeMessageCount = true;
|
||||
if (p.SortProperty == SortProperty.CreationDate) p.IncludeCreated = true;
|
||||
|
||||
// Done!
|
||||
return p;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public static async Task RenderMemberList(this Context ctx, LookupContext lookupCtx, IDatabase db, SystemId system, string embedTitle, string color, MemberListOptions opts)
|
||||
void ShortRenderer(EmbedBuilder eb, IEnumerable<ListedMember> page)
|
||||
{
|
||||
// We take an IDatabase instead of a IPKConnection so we don't keep the handle open for the entire runtime
|
||||
// We wanna release it as soon as the member list is actually *fetched*, instead of potentially minutes later (paginate timeout)
|
||||
var members = (await db.Execute(conn => conn.QueryMemberList(system, opts.ToQueryOptions())))
|
||||
.SortByMemberListOptions(opts, lookupCtx)
|
||||
.ToList();
|
||||
|
||||
var itemsPerPage = opts.Type == ListType.Short ? 25 : 5;
|
||||
await ctx.Paginate(members.ToAsyncEnumerable(), members.Count, itemsPerPage, embedTitle, color, Renderer);
|
||||
|
||||
// Base renderer, dispatches based on type
|
||||
Task Renderer(EmbedBuilder eb, IEnumerable<ListedMember> page)
|
||||
// We may end up over the description character limit
|
||||
// so run it through a helper that "makes it work" :)
|
||||
eb.WithSimpleLineContent(page.Select(m =>
|
||||
{
|
||||
// Add a global footer with the filter/sort string + result count
|
||||
eb.Footer(new($"{opts.CreateFilterString()}. {"result".ToQuantity(members.Count)}."));
|
||||
var ret = $"[`{m.Hid}`] **{m.NameFor(ctx)}** ";
|
||||
|
||||
// Then call the specific renderers
|
||||
if (opts.Type == ListType.Short)
|
||||
ShortRenderer(eb, page);
|
||||
else
|
||||
LongRenderer(eb, page);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
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" :)
|
||||
eb.WithSimpleLineContent(page.Select(m =>
|
||||
switch (opts.SortProperty)
|
||||
{
|
||||
var ret = $"[`{m.Hid}`] **{m.NameFor(ctx)}** ";
|
||||
|
||||
switch (opts.SortProperty)
|
||||
{
|
||||
case SortProperty.Birthdate:
|
||||
case SortProperty.Birthdate:
|
||||
{
|
||||
var birthday = m.BirthdayFor(lookupCtx);
|
||||
if (birthday != null)
|
||||
ret += $"(birthday: {m.BirthdayString})";
|
||||
break;
|
||||
}
|
||||
case SortProperty.DisplayName:
|
||||
{
|
||||
if (m.DisplayName != null)
|
||||
ret += $"({m.DisplayName})";
|
||||
break;
|
||||
}
|
||||
case SortProperty.MessageCount:
|
||||
{
|
||||
if (m.MessageCountFor(lookupCtx) is { } count)
|
||||
ret += $"({count} messages)";
|
||||
break;
|
||||
}
|
||||
case SortProperty.LastSwitch:
|
||||
{
|
||||
if (m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw))
|
||||
ret += $"(last switched in: <t:{lastSw.Value.ToUnixTimeSeconds()}>)";
|
||||
break;
|
||||
}
|
||||
// case SortProperty.LastMessage:
|
||||
// {
|
||||
// if (m.MetadataPrivacy.TryGet(lookupCtx, m.LastMessage, out var lastMsg))
|
||||
// ret += $"(last message: <t:{DiscordUtils.SnowflakeToInstant(lastMsg.Value).ToUnixTimeSeconds()}>)";
|
||||
// break;
|
||||
// }
|
||||
case SortProperty.CreationDate:
|
||||
{
|
||||
if (m.MetadataPrivacy.TryGet(lookupCtx, m.Created, out var created))
|
||||
ret += $"(created at <t:{created.ToUnixTimeSeconds()}>)";
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
if (opts.IncludeMessageCount && m.MessageCountFor(lookupCtx) is { } count)
|
||||
{
|
||||
var birthday = m.BirthdayFor(lookupCtx);
|
||||
if (birthday != null)
|
||||
ret += $"(birthday: {m.BirthdayString})";
|
||||
break;
|
||||
ret += $"({count} messages)";
|
||||
}
|
||||
case SortProperty.DisplayName:
|
||||
else if (opts.IncludeLastSwitch &&
|
||||
m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw))
|
||||
{
|
||||
if (m.DisplayName != null)
|
||||
ret += $"({m.DisplayName})";
|
||||
break;
|
||||
ret += $"(last switched in: <t:{lastSw.Value.ToUnixTimeSeconds()}>)";
|
||||
}
|
||||
case SortProperty.MessageCount:
|
||||
// else if (opts.IncludeLastMessage && m.MetadataPrivacy.TryGet(lookupCtx, m.LastMessage, out var lastMsg))
|
||||
// ret += $"(last message: <t:{DiscordUtils.SnowflakeToInstant(lastMsg.Value).ToUnixTimeSeconds()}>)";
|
||||
else if (opts.IncludeCreated &&
|
||||
m.MetadataPrivacy.TryGet(lookupCtx, m.Created, out var created))
|
||||
{
|
||||
if (m.MessageCountFor(lookupCtx) is { } count)
|
||||
ret += $"({count} messages)";
|
||||
break;
|
||||
ret += $"(created at <t:{created.ToUnixTimeSeconds()}>)";
|
||||
}
|
||||
case SortProperty.LastSwitch:
|
||||
else if (opts.IncludeAvatar && m.AvatarFor(lookupCtx) is { } avatarUrl)
|
||||
{
|
||||
if (m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw))
|
||||
ret += $"(last switched in: <t:{lastSw.Value.ToUnixTimeSeconds()}>)";
|
||||
break;
|
||||
ret += $"([avatar URL]({avatarUrl}))";
|
||||
}
|
||||
// case SortProperty.LastMessage:
|
||||
// {
|
||||
// if (m.MetadataPrivacy.TryGet(lookupCtx, m.LastMessage, out var lastMsg))
|
||||
// ret += $"(last message: <t:{DiscordUtils.SnowflakeToInstant(lastMsg.Value).ToUnixTimeSeconds()}>)";
|
||||
// break;
|
||||
// }
|
||||
case SortProperty.CreationDate:
|
||||
else if (opts.IncludePronouns && m.PronounsFor(lookupCtx) is { } pronouns)
|
||||
{
|
||||
if (m.MetadataPrivacy.TryGet(lookupCtx, m.Created, out var created))
|
||||
ret += $"(created at <t:{created.ToUnixTimeSeconds()}>)";
|
||||
break;
|
||||
ret += $"({pronouns})";
|
||||
}
|
||||
default:
|
||||
else if (m.HasProxyTags)
|
||||
{
|
||||
if (opts.IncludeMessageCount && m.MessageCountFor(lookupCtx) is { } count)
|
||||
ret += $"({count} messages)";
|
||||
else if (opts.IncludeLastSwitch && m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw))
|
||||
ret += $"(last switched in: <t:{lastSw.Value.ToUnixTimeSeconds()}>)";
|
||||
// else if (opts.IncludeLastMessage && m.MetadataPrivacy.TryGet(lookupCtx, m.LastMessage, out var lastMsg))
|
||||
// ret += $"(last message: <t:{DiscordUtils.SnowflakeToInstant(lastMsg.Value).ToUnixTimeSeconds()}>)";
|
||||
else if (opts.IncludeCreated && m.MetadataPrivacy.TryGet(lookupCtx, m.Created, out var created))
|
||||
ret += $"(created at <t:{created.ToUnixTimeSeconds()}>)";
|
||||
else if (opts.IncludeAvatar && m.AvatarFor(lookupCtx) is { } avatarUrl)
|
||||
ret += $"([avatar URL]({avatarUrl}))";
|
||||
else if (opts.IncludePronouns && m.PronounsFor(lookupCtx) is { } pronouns)
|
||||
ret += $"({pronouns})";
|
||||
else if (m.HasProxyTags)
|
||||
{
|
||||
var proxyTagsString = m.ProxyTagsString();
|
||||
if (proxyTagsString.Length > 100) // arbitrary threshold for now, tweak?
|
||||
proxyTagsString = "tags too long, see member card";
|
||||
ret += $"*(*{proxyTagsString}*)*";
|
||||
}
|
||||
break;
|
||||
var proxyTagsString = m.ProxyTagsString();
|
||||
if (proxyTagsString.Length > 100) // arbitrary threshold for now, tweak?
|
||||
proxyTagsString = "tags too long, see member card";
|
||||
ret += $"*(*{proxyTagsString}*)*";
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}));
|
||||
}
|
||||
|
||||
void LongRenderer(EmbedBuilder eb, IEnumerable<ListedMember> page)
|
||||
{
|
||||
var zone = ctx.System?.Zone ?? DateTimeZone.Utc;
|
||||
foreach (var m in page)
|
||||
{
|
||||
var profile = new StringBuilder($"**ID**: {m.Hid}");
|
||||
|
||||
if (m.DisplayName != null && m.NamePrivacy.CanAccess(lookupCtx))
|
||||
profile.Append($"\n**Display name**: {m.DisplayName}");
|
||||
|
||||
if (m.PronounsFor(lookupCtx) is { } pronouns)
|
||||
profile.Append($"\n**Pronouns**: {pronouns}");
|
||||
|
||||
if (m.BirthdayFor(lookupCtx) != null)
|
||||
profile.Append($"\n**Birthdate**: {m.BirthdayString}");
|
||||
|
||||
if (m.ProxyTags.Count > 0)
|
||||
profile.Append($"\n**Proxy tags**: {m.ProxyTagsString()}");
|
||||
|
||||
if ((opts.IncludeMessageCount || opts.SortProperty == SortProperty.MessageCount) && m.MessageCountFor(lookupCtx) is { } count && count > 0)
|
||||
profile.Append($"\n**Message count:** {count}");
|
||||
|
||||
// if ((opts.IncludeLastMessage || opts.SortProperty == SortProperty.LastMessage) && m.MetadataPrivacy.TryGet(lookupCtx, m.LastMessage, out var lastMsg))
|
||||
// profile.Append($"\n**Last message:** {DiscordUtils.SnowflakeToInstant(lastMsg.Value).FormatZoned(zone)}");
|
||||
|
||||
if ((opts.IncludeLastSwitch || opts.SortProperty == SortProperty.LastSwitch) && m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw))
|
||||
profile.Append($"\n**Last switched in:** {lastSw.Value.FormatZoned(zone)}");
|
||||
|
||||
if ((opts.IncludeCreated || opts.SortProperty == SortProperty.CreationDate) && m.MetadataPrivacy.TryGet(lookupCtx, m.Created, out var created))
|
||||
profile.Append($"\n**Created on:** {created.FormatZoned(zone)}");
|
||||
|
||||
if (opts.IncludeAvatar && m.AvatarFor(lookupCtx) is { } avatar)
|
||||
profile.Append($"\n**Avatar URL:** {avatar.TryGetCleanCdnUrl()}");
|
||||
|
||||
if (m.DescriptionFor(lookupCtx) is { } desc)
|
||||
profile.Append($"\n\n{desc}");
|
||||
|
||||
if (m.MemberVisibility == PrivacyLevel.Private)
|
||||
profile.Append("\n*(this member is hidden)*");
|
||||
|
||||
eb.Field(new(m.NameFor(ctx), profile.ToString().Truncate(1024)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}));
|
||||
}
|
||||
|
||||
void LongRenderer(EmbedBuilder eb, IEnumerable<ListedMember> page)
|
||||
{
|
||||
var zone = ctx.System?.Zone ?? DateTimeZone.Utc;
|
||||
foreach (var m in page)
|
||||
{
|
||||
var profile = new StringBuilder($"**ID**: {m.Hid}");
|
||||
|
||||
if (m.DisplayName != null && m.NamePrivacy.CanAccess(lookupCtx))
|
||||
profile.Append($"\n**Display name**: {m.DisplayName}");
|
||||
|
||||
if (m.PronounsFor(lookupCtx) is { } pronouns)
|
||||
profile.Append($"\n**Pronouns**: {pronouns}");
|
||||
|
||||
if (m.BirthdayFor(lookupCtx) != null)
|
||||
profile.Append($"\n**Birthdate**: {m.BirthdayString}");
|
||||
|
||||
if (m.ProxyTags.Count > 0)
|
||||
profile.Append($"\n**Proxy tags**: {m.ProxyTagsString()}");
|
||||
|
||||
if ((opts.IncludeMessageCount || opts.SortProperty == SortProperty.MessageCount) &&
|
||||
m.MessageCountFor(lookupCtx) is { } count && count > 0)
|
||||
profile.Append($"\n**Message count:** {count}");
|
||||
|
||||
// if ((opts.IncludeLastMessage || opts.SortProperty == SortProperty.LastMessage) && m.MetadataPrivacy.TryGet(lookupCtx, m.LastMessage, out var lastMsg))
|
||||
// profile.Append($"\n**Last message:** {DiscordUtils.SnowflakeToInstant(lastMsg.Value).FormatZoned(zone)}");
|
||||
|
||||
if ((opts.IncludeLastSwitch || opts.SortProperty == SortProperty.LastSwitch) &&
|
||||
m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw))
|
||||
profile.Append($"\n**Last switched in:** {lastSw.Value.FormatZoned(zone)}");
|
||||
|
||||
if ((opts.IncludeCreated || opts.SortProperty == SortProperty.CreationDate) &&
|
||||
m.MetadataPrivacy.TryGet(lookupCtx, m.Created, out var created))
|
||||
profile.Append($"\n**Created on:** {created.FormatZoned(zone)}");
|
||||
|
||||
if (opts.IncludeAvatar && m.AvatarFor(lookupCtx) is { } avatar)
|
||||
profile.Append($"\n**Avatar URL:** {avatar.TryGetCleanCdnUrl()}");
|
||||
|
||||
if (m.DescriptionFor(lookupCtx) is { } desc)
|
||||
profile.Append($"\n\n{desc}");
|
||||
|
||||
if (m.MemberVisibility == PrivacyLevel.Private)
|
||||
profile.Append("\n*(this member is hidden)*");
|
||||
|
||||
eb.Field(new Embed.Field(m.NameFor(ctx), profile.ToString().Truncate(1024)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
using NodaTime;
|
||||
|
|
@ -8,128 +5,127 @@ using NodaTime;
|
|||
using PluralKit.Core;
|
||||
|
||||
#nullable enable
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class MemberListOptions
|
||||
{
|
||||
public class MemberListOptions
|
||||
public SortProperty SortProperty { get; set; } = SortProperty.Name;
|
||||
public bool Reverse { get; set; }
|
||||
|
||||
public PrivacyLevel? PrivacyFilter { get; set; } = PrivacyLevel.Public;
|
||||
public GroupId? GroupFilter { get; set; }
|
||||
public string? Search { get; set; }
|
||||
public bool SearchDescription { get; set; }
|
||||
|
||||
public ListType Type { get; set; }
|
||||
public bool IncludeMessageCount { get; set; }
|
||||
public bool IncludeLastSwitch { get; set; }
|
||||
public bool IncludeLastMessage { get; set; }
|
||||
public bool IncludeCreated { get; set; }
|
||||
public bool IncludeAvatar { get; set; }
|
||||
public bool IncludePronouns { get; set; }
|
||||
|
||||
public string CreateFilterString()
|
||||
{
|
||||
public SortProperty SortProperty { get; set; } = SortProperty.Name;
|
||||
public bool Reverse { get; set; }
|
||||
|
||||
public PrivacyLevel? PrivacyFilter { get; set; } = PrivacyLevel.Public;
|
||||
public GroupId? GroupFilter { get; set; }
|
||||
public string? Search { get; set; }
|
||||
public bool SearchDescription { get; set; }
|
||||
|
||||
public ListType Type { get; set; }
|
||||
public bool IncludeMessageCount { get; set; }
|
||||
public bool IncludeLastSwitch { get; set; }
|
||||
public bool IncludeLastMessage { get; set; }
|
||||
public bool IncludeCreated { get; set; }
|
||||
public bool IncludeAvatar { get; set; }
|
||||
public bool IncludePronouns { get; set; }
|
||||
|
||||
public string CreateFilterString()
|
||||
var str = new StringBuilder();
|
||||
str.Append("Sorting ");
|
||||
if (SortProperty != SortProperty.Random) str.Append("by ");
|
||||
str.Append(SortProperty switch
|
||||
{
|
||||
var str = new StringBuilder();
|
||||
str.Append("Sorting ");
|
||||
if (SortProperty != SortProperty.Random) str.Append("by ");
|
||||
str.Append(SortProperty switch
|
||||
{
|
||||
SortProperty.Name => "member name",
|
||||
SortProperty.Hid => "member ID",
|
||||
SortProperty.DisplayName => "display name",
|
||||
SortProperty.CreationDate => "creation date",
|
||||
SortProperty.LastMessage => "last message",
|
||||
SortProperty.LastSwitch => "last switch",
|
||||
SortProperty.MessageCount => "message count",
|
||||
SortProperty.Birthdate => "birthday",
|
||||
SortProperty.Random => "randomly",
|
||||
_ => new ArgumentOutOfRangeException($"Couldn't find readable string for sort property {SortProperty}")
|
||||
});
|
||||
SortProperty.Name => "member name",
|
||||
SortProperty.Hid => "member ID",
|
||||
SortProperty.DisplayName => "display name",
|
||||
SortProperty.CreationDate => "creation date",
|
||||
SortProperty.LastMessage => "last message",
|
||||
SortProperty.LastSwitch => "last switch",
|
||||
SortProperty.MessageCount => "message count",
|
||||
SortProperty.Birthdate => "birthday",
|
||||
SortProperty.Random => "randomly",
|
||||
_ => new ArgumentOutOfRangeException($"Couldn't find readable string for sort property {SortProperty}")
|
||||
});
|
||||
|
||||
if (Search != null)
|
||||
{
|
||||
str.Append($", searching for \"{Search}\"");
|
||||
if (SearchDescription) str.Append(" (including description)");
|
||||
}
|
||||
|
||||
str.Append(PrivacyFilter switch
|
||||
{
|
||||
null => ", showing all members",
|
||||
PrivacyLevel.Private => ", showing only private members",
|
||||
PrivacyLevel.Public => "", // (default, no extra line needed)
|
||||
_ => new ArgumentOutOfRangeException($"Couldn't find readable string for privacy filter {PrivacyFilter}")
|
||||
});
|
||||
|
||||
return str.ToString();
|
||||
if (Search != null)
|
||||
{
|
||||
str.Append($", searching for \"{Search}\"");
|
||||
if (SearchDescription) str.Append(" (including description)");
|
||||
}
|
||||
|
||||
public DatabaseViewsExt.MemberListQueryOptions ToQueryOptions() =>
|
||||
new DatabaseViewsExt.MemberListQueryOptions
|
||||
{
|
||||
PrivacyFilter = PrivacyFilter,
|
||||
GroupFilter = GroupFilter,
|
||||
Search = Search,
|
||||
SearchDescription = SearchDescription
|
||||
};
|
||||
}
|
||||
|
||||
public static class MemberListOptionsExt
|
||||
{
|
||||
public static IEnumerable<ListedMember> SortByMemberListOptions(this IEnumerable<ListedMember> input, MemberListOptions opts, LookupContext ctx)
|
||||
str.Append(PrivacyFilter switch
|
||||
{
|
||||
IComparer<T> ReverseMaybe<T>(IComparer<T> c) =>
|
||||
opts.Reverse ? Comparer<T>.Create((a, b) => c.Compare(b, a)) : c;
|
||||
null => ", showing all members",
|
||||
PrivacyLevel.Private => ", showing only private members",
|
||||
PrivacyLevel.Public => "", // (default, no extra line needed)
|
||||
_ => new ArgumentOutOfRangeException(
|
||||
$"Couldn't find readable string for privacy filter {PrivacyFilter}")
|
||||
});
|
||||
|
||||
var randGen = new global::System.Random();
|
||||
|
||||
var culture = StringComparer.InvariantCultureIgnoreCase;
|
||||
return (opts.SortProperty switch
|
||||
{
|
||||
// As for the OrderByDescending HasValue calls: https://www.jerriepelser.com/blog/orderby-with-null-values/
|
||||
// We want nulls last no matter what, even if orders are reversed
|
||||
SortProperty.Hid => input.OrderBy(m => m.Hid, ReverseMaybe(culture)),
|
||||
SortProperty.Name => input.OrderBy(m => m.NameFor(ctx), ReverseMaybe(culture)),
|
||||
SortProperty.CreationDate => input.OrderBy(m => m.Created, ReverseMaybe(Comparer<Instant>.Default)),
|
||||
SortProperty.MessageCount => input.OrderByDescending(m => m.MessageCount, ReverseMaybe(Comparer<int>.Default)),
|
||||
SortProperty.DisplayName => input
|
||||
.OrderByDescending(m => m.DisplayName != null)
|
||||
.ThenBy(m => m.DisplayName, ReverseMaybe(culture)),
|
||||
SortProperty.Birthdate => input
|
||||
.OrderByDescending(m => m.AnnualBirthday.HasValue)
|
||||
.ThenBy(m => m.AnnualBirthday, ReverseMaybe(Comparer<AnnualDate?>.Default)),
|
||||
SortProperty.LastMessage => throw new PKError("Sorting by last message is temporarily disabled due to database issues, sorry."),
|
||||
// SortProperty.LastMessage => input
|
||||
// .OrderByDescending(m => m.LastMessage.HasValue)
|
||||
// .ThenByDescending(m => m.LastMessage, ReverseMaybe(Comparer<ulong?>.Default)),
|
||||
SortProperty.LastSwitch => input
|
||||
.OrderByDescending(m => m.LastSwitchTime.HasValue)
|
||||
.ThenByDescending(m => m.LastSwitchTime, ReverseMaybe(Comparer<Instant?>.Default)),
|
||||
SortProperty.Random => input
|
||||
.OrderBy(m => randGen.Next()),
|
||||
_ => throw new ArgumentOutOfRangeException($"Unknown sort property {opts.SortProperty}")
|
||||
})
|
||||
// Lastly, add a by-name fallback order for collisions (generally hits w/ lots of null values)
|
||||
.ThenBy(m => m.NameFor(ctx), culture);
|
||||
}
|
||||
return str.ToString();
|
||||
}
|
||||
|
||||
public enum SortProperty
|
||||
public DatabaseViewsExt.MemberListQueryOptions ToQueryOptions() =>
|
||||
new()
|
||||
{
|
||||
PrivacyFilter = PrivacyFilter,
|
||||
GroupFilter = GroupFilter,
|
||||
Search = Search,
|
||||
SearchDescription = SearchDescription
|
||||
};
|
||||
}
|
||||
|
||||
public static class MemberListOptionsExt
|
||||
{
|
||||
public static IEnumerable<ListedMember> SortByMemberListOptions(this IEnumerable<ListedMember> input,
|
||||
MemberListOptions opts, LookupContext ctx)
|
||||
{
|
||||
Name,
|
||||
DisplayName,
|
||||
Hid,
|
||||
MessageCount,
|
||||
CreationDate,
|
||||
LastSwitch,
|
||||
LastMessage,
|
||||
Birthdate,
|
||||
Random
|
||||
}
|
||||
IComparer<T> ReverseMaybe<T>(IComparer<T> c) =>
|
||||
opts.Reverse ? Comparer<T>.Create((a, b) => c.Compare(b, a)) : c;
|
||||
|
||||
public enum ListType
|
||||
{
|
||||
Short,
|
||||
Long
|
||||
var randGen = new global::System.Random();
|
||||
|
||||
var culture = StringComparer.InvariantCultureIgnoreCase;
|
||||
return (opts.SortProperty switch
|
||||
{
|
||||
// As for the OrderByDescending HasValue calls: https://www.jerriepelser.com/blog/orderby-with-null-values/
|
||||
// We want nulls last no matter what, even if orders are reversed
|
||||
SortProperty.Hid => input.OrderBy(m => m.Hid, ReverseMaybe(culture)),
|
||||
SortProperty.Name => input.OrderBy(m => m.NameFor(ctx), ReverseMaybe(culture)),
|
||||
SortProperty.CreationDate => input.OrderBy(m => m.Created, ReverseMaybe(Comparer<Instant>.Default)),
|
||||
SortProperty.MessageCount => input.OrderByDescending(m => m.MessageCount,
|
||||
ReverseMaybe(Comparer<int>.Default)),
|
||||
SortProperty.DisplayName => input
|
||||
.OrderByDescending(m => m.DisplayName != null)
|
||||
.ThenBy(m => m.DisplayName, ReverseMaybe(culture)),
|
||||
SortProperty.Birthdate => input
|
||||
.OrderByDescending(m => m.AnnualBirthday.HasValue)
|
||||
.ThenBy(m => m.AnnualBirthday, ReverseMaybe(Comparer<AnnualDate?>.Default)),
|
||||
SortProperty.LastMessage => throw new PKError(
|
||||
"Sorting by last message is temporarily disabled due to database issues, sorry."),
|
||||
// SortProperty.LastMessage => input
|
||||
// .OrderByDescending(m => m.LastMessage.HasValue)
|
||||
// .ThenByDescending(m => m.LastMessage, ReverseMaybe(Comparer<ulong?>.Default)),
|
||||
SortProperty.LastSwitch => input
|
||||
.OrderByDescending(m => m.LastSwitchTime.HasValue)
|
||||
.ThenByDescending(m => m.LastSwitchTime, ReverseMaybe(Comparer<Instant?>.Default)),
|
||||
SortProperty.Random => input
|
||||
.OrderBy(m => randGen.Next()),
|
||||
_ => throw new ArgumentOutOfRangeException($"Unknown sort property {opts.SortProperty}")
|
||||
})
|
||||
// Lastly, add a by-name fallback order for collisions (generally hits w/ lots of null values)
|
||||
.ThenBy(m => m.NameFor(ctx), culture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum SortProperty
|
||||
{
|
||||
Name,
|
||||
DisplayName,
|
||||
Hid,
|
||||
MessageCount,
|
||||
CreationDate,
|
||||
LastSwitch,
|
||||
LastMessage,
|
||||
Birthdate,
|
||||
Random
|
||||
}
|
||||
|
||||
public enum ListType { Short, Long }
|
||||
|
|
@ -1,9 +1,5 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Web;
|
||||
using System.Linq;
|
||||
|
||||
using Dapper;
|
||||
|
||||
|
|
@ -13,131 +9,137 @@ using Newtonsoft.Json.Linq;
|
|||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
{
|
||||
public class Member
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
private readonly EmbedService _embeds;
|
||||
private readonly HttpClient _client;
|
||||
private readonly DispatchService _dispatch;
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public Member(EmbedService embeds, IDatabase db, ModelRepository repo, HttpClient client, DispatchService dispatch)
|
||||
public class Member
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly IDatabase _db;
|
||||
private readonly DispatchService _dispatch;
|
||||
private readonly EmbedService _embeds;
|
||||
private readonly ModelRepository _repo;
|
||||
|
||||
public Member(EmbedService embeds, IDatabase db, ModelRepository repo, HttpClient client,
|
||||
DispatchService dispatch)
|
||||
{
|
||||
_embeds = embeds;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
_client = client;
|
||||
_dispatch = dispatch;
|
||||
}
|
||||
|
||||
public async Task NewMember(Context ctx)
|
||||
{
|
||||
if (ctx.System == null) throw Errors.NoSystemError;
|
||||
var memberName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a member name.");
|
||||
|
||||
// Hard name length cap
|
||||
if (memberName.Length > Limits.MaxMemberNameLength)
|
||||
throw Errors.StringTooLongError("Member name", memberName.Length, Limits.MaxMemberNameLength);
|
||||
|
||||
// Warn if there's already a member by this name
|
||||
var existingMember = await _repo.GetMemberByName(ctx.System.Id, memberName);
|
||||
if (existingMember != null)
|
||||
{
|
||||
_embeds = embeds;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
_client = client;
|
||||
_dispatch = dispatch;
|
||||
var msg = $"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx)}\" (with ID `{existingMember.Hid}`). Do you want to create another member with the same name?";
|
||||
if (!await ctx.PromptYesNo(msg, "Create")) throw new PKError("Member creation cancelled.");
|
||||
}
|
||||
|
||||
public async Task NewMember(Context ctx)
|
||||
{
|
||||
if (ctx.System == null) throw Errors.NoSystemError;
|
||||
var memberName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a member name.");
|
||||
await using var conn = await _db.Obtain();
|
||||
|
||||
// Hard name length cap
|
||||
if (memberName.Length > Limits.MaxMemberNameLength)
|
||||
throw Errors.StringTooLongError("Member name", memberName.Length, Limits.MaxMemberNameLength);
|
||||
// Enforce per-system member limit
|
||||
var memberCount = await _repo.GetSystemMemberCount(ctx.System.Id);
|
||||
var memberLimit = ctx.System.MemberLimitOverride ?? Limits.MaxMemberCount;
|
||||
if (memberCount >= memberLimit)
|
||||
throw Errors.MemberLimitReachedError(memberLimit);
|
||||
|
||||
// Warn if there's already a member by this name
|
||||
var existingMember = await _repo.GetMemberByName(ctx.System.Id, memberName);
|
||||
if (existingMember != null)
|
||||
// Create the member
|
||||
var member = await _repo.CreateMember(ctx.System.Id, memberName);
|
||||
memberCount++;
|
||||
|
||||
// Try to match an image attached to the message
|
||||
var avatarArg = ctx.Message.Attachments.FirstOrDefault();
|
||||
Exception imageMatchError = null;
|
||||
var sentDispatch = false;
|
||||
if (avatarArg != null)
|
||||
try
|
||||
{
|
||||
var msg = $"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx)}\" (with ID `{existingMember.Hid}`). Do you want to create another member with the same name?";
|
||||
if (!await ctx.PromptYesNo(msg, "Create")) throw new PKError("Member creation cancelled.");
|
||||
}
|
||||
await AvatarUtils.VerifyAvatarOrThrow(_client, avatarArg.Url);
|
||||
await _db.Execute(conn =>
|
||||
_repo.UpdateMember(member.Id, new MemberPatch { AvatarUrl = avatarArg.Url }, conn));
|
||||
|
||||
await using var conn = await _db.Obtain();
|
||||
|
||||
// Enforce per-system member limit
|
||||
var memberCount = await _repo.GetSystemMemberCount(ctx.System.Id);
|
||||
var memberLimit = ctx.System.MemberLimitOverride ?? Limits.MaxMemberCount;
|
||||
if (memberCount >= memberLimit)
|
||||
throw Errors.MemberLimitReachedError(memberLimit);
|
||||
|
||||
// Create the member
|
||||
var member = await _repo.CreateMember(ctx.System.Id, memberName);
|
||||
memberCount++;
|
||||
|
||||
// Try to match an image attached to the message
|
||||
var avatarArg = ctx.Message.Attachments.FirstOrDefault();
|
||||
Exception imageMatchError = null;
|
||||
bool sentDispatch = false;
|
||||
if (avatarArg != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await AvatarUtils.VerifyAvatarOrThrow(_client, avatarArg.Url);
|
||||
await _db.Execute(conn => _repo.UpdateMember(member.Id, new MemberPatch { AvatarUrl = avatarArg.Url }, conn));
|
||||
|
||||
_ = _dispatch.Dispatch(member.Id, new()
|
||||
{
|
||||
Event = DispatchEvent.CREATE_MEMBER,
|
||||
EventData = JObject.FromObject(new { name = memberName, avatar_url = avatarArg.Url }),
|
||||
});
|
||||
sentDispatch = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
imageMatchError = e;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sentDispatch)
|
||||
_ = _dispatch.Dispatch(member.Id, new()
|
||||
_ = _dispatch.Dispatch(member.Id, new UpdateDispatchData
|
||||
{
|
||||
Event = DispatchEvent.CREATE_MEMBER,
|
||||
EventData = JObject.FromObject(new { name = memberName }),
|
||||
EventData = JObject.FromObject(new { name = memberName, avatar_url = avatarArg.Url }),
|
||||
});
|
||||
sentDispatch = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
imageMatchError = e;
|
||||
}
|
||||
|
||||
// Send confirmation and space hint
|
||||
await ctx.Reply($"{Emojis.Success} Member \"{memberName}\" (`{member.Hid}`) registered! Check out the getting started page for how to get a member up and running: https://pluralkit.me/start#create-a-member");
|
||||
// todo: move this to ModelRepository
|
||||
if (await _db.Execute(conn => conn.QuerySingleAsync<bool>("select has_private_members(@System)",
|
||||
if (!sentDispatch)
|
||||
_ = _dispatch.Dispatch(member.Id, new UpdateDispatchData
|
||||
{
|
||||
Event = DispatchEvent.CREATE_MEMBER,
|
||||
EventData = JObject.FromObject(new { name = memberName }),
|
||||
});
|
||||
|
||||
// Send confirmation and space hint
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Success} Member \"{memberName}\" (`{member.Hid}`) registered! Check out the getting started page for how to get a member up and running: https://pluralkit.me/start#create-a-member");
|
||||
// todo: move this to ModelRepository
|
||||
if (await _db.Execute(conn => conn.QuerySingleAsync<bool>("select has_private_members(@System)",
|
||||
new { System = ctx.System.Id }))) //if has private members
|
||||
await ctx.Reply($"{Emojis.Warn} This member is currently **public**. To change this, use `pk;member {member.Hid} private`.");
|
||||
if (avatarArg != null)
|
||||
if (imageMatchError == null)
|
||||
await ctx.Reply($"{Emojis.Success} Member avatar set to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the avatar will stop working.");
|
||||
else
|
||||
await ctx.Reply($"{Emojis.Error} Couldn't set avatar: {imageMatchError.Message}");
|
||||
if (memberName.Contains(" "))
|
||||
await ctx.Reply($"{Emojis.Note} Note that this member's name contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it, or just use the member's 5-character ID (which is `{member.Hid}`).");
|
||||
if (memberCount >= memberLimit)
|
||||
await ctx.Reply($"{Emojis.Warn} You have reached the per-system member limit ({memberLimit}). You will be unable to create additional members until existing members are deleted.");
|
||||
else if (memberCount >= Limits.WarnThreshold(memberLimit))
|
||||
await ctx.Reply($"{Emojis.Warn} You are approaching the per-system member limit ({memberCount} / {memberLimit} members). Please review your member list for unused or duplicate members.");
|
||||
}
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Warn} This member is currently **public**. To change this, use `pk;member {member.Hid} private`.");
|
||||
if (avatarArg != null)
|
||||
if (imageMatchError == null)
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Success} Member avatar set to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the avatar will stop working.");
|
||||
else
|
||||
await ctx.Reply($"{Emojis.Error} Couldn't set avatar: {imageMatchError.Message}");
|
||||
if (memberName.Contains(" "))
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Note} Note that this member's name contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it, or just use the member's 5-character ID (which is `{member.Hid}`).");
|
||||
if (memberCount >= memberLimit)
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Warn} You have reached the per-system member limit ({memberLimit}). You will be unable to create additional members until existing members are deleted.");
|
||||
else if (memberCount >= Limits.WarnThreshold(memberLimit))
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Warn} You are approaching the per-system member limit ({memberCount} / {memberLimit} members). Please review your member list for unused or duplicate members.");
|
||||
}
|
||||
|
||||
public async Task ViewMember(Context ctx, PKMember target)
|
||||
{
|
||||
var system = await _repo.GetSystem(target.System);
|
||||
await ctx.Reply(embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.LookupContextFor(system)));
|
||||
}
|
||||
public async Task ViewMember(Context ctx, PKMember target)
|
||||
{
|
||||
var system = await _repo.GetSystem(target.System);
|
||||
await ctx.Reply(
|
||||
embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.LookupContextFor(system)));
|
||||
}
|
||||
|
||||
public async Task Soulscream(Context ctx, PKMember target)
|
||||
{
|
||||
// this is for a meme, please don't take this code seriously. :)
|
||||
public async Task Soulscream(Context ctx, PKMember target)
|
||||
{
|
||||
// this is for a meme, please don't take this code seriously. :)
|
||||
|
||||
var name = target.NameFor(ctx.LookupContextFor(target));
|
||||
var encoded = HttpUtility.UrlEncode(name);
|
||||
var name = target.NameFor(ctx.LookupContextFor(target));
|
||||
var encoded = HttpUtility.UrlEncode(name);
|
||||
|
||||
var resp = await _client.GetAsync($"https://onomancer.sibr.dev/api/generateStats2?name={encoded}");
|
||||
if (resp.StatusCode != HttpStatusCode.OK)
|
||||
// lol
|
||||
return;
|
||||
var resp = await _client.GetAsync($"https://onomancer.sibr.dev/api/generateStats2?name={encoded}");
|
||||
if (resp.StatusCode != HttpStatusCode.OK)
|
||||
// lol
|
||||
return;
|
||||
|
||||
var data = JObject.Parse(await resp.Content.ReadAsStringAsync());
|
||||
var scream = data["soulscream"]!.Value<string>();
|
||||
var data = JObject.Parse(await resp.Content.ReadAsStringAsync());
|
||||
var scream = data["soulscream"]!.Value<string>();
|
||||
|
||||
var eb = new EmbedBuilder()
|
||||
.Color(DiscordUtils.Red)
|
||||
.Title(name)
|
||||
.Url($"https://onomancer.sibr.dev/reflect?name={encoded}")
|
||||
.Description($"*{scream}*");
|
||||
await ctx.Reply(embed: eb.Build());
|
||||
}
|
||||
var eb = new EmbedBuilder()
|
||||
.Color(DiscordUtils.Red)
|
||||
.Title(name)
|
||||
.Url($"https://onomancer.sibr.dev/reflect?name={encoded}")
|
||||
.Description($"*{scream}*");
|
||||
await ctx.Reply(embed: eb.Build());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,164 +1,170 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Myriad.Builders;
|
||||
using Myriad.Types;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class MemberAvatar
|
||||
{
|
||||
public class MemberAvatar
|
||||
private readonly HttpClient _client;
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
|
||||
public MemberAvatar(IDatabase db, ModelRepository repo, HttpClient client)
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
private readonly HttpClient _client;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public MemberAvatar(IDatabase db, ModelRepository repo, HttpClient client)
|
||||
private async Task AvatarClear(AvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? mgs)
|
||||
{
|
||||
await UpdateAvatar(location, ctx, target, null);
|
||||
if (location == AvatarLocation.Server)
|
||||
{
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
private async Task AvatarClear(AvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? mgs)
|
||||
{
|
||||
await UpdateAvatar(location, ctx, target, null);
|
||||
if (location == AvatarLocation.Server)
|
||||
{
|
||||
if (target.AvatarUrl != null)
|
||||
await ctx.Reply($"{Emojis.Success} Member server avatar cleared. This member will now use the global avatar in this server (**{ctx.Guild.Name}**).");
|
||||
else
|
||||
await ctx.Reply($"{Emojis.Success} Member server avatar cleared. This member now has no avatar.");
|
||||
}
|
||||
if (target.AvatarUrl != null)
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Success} Member server avatar cleared. This member will now use the global avatar in this server (**{ctx.Guild.Name}**).");
|
||||
else
|
||||
{
|
||||
if (mgs?.AvatarUrl != null)
|
||||
await ctx.Reply($"{Emojis.Success} Member avatar cleared. Note that this member has a server-specific avatar set here, type `pk;member {target.Reference()} serveravatar clear` if you wish to clear that too.");
|
||||
else
|
||||
await ctx.Reply($"{Emojis.Success} Member avatar cleared.");
|
||||
}
|
||||
await ctx.Reply($"{Emojis.Success} Member server avatar cleared. This member now has no avatar.");
|
||||
}
|
||||
|
||||
private async Task AvatarShow(AvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? guildData)
|
||||
else
|
||||
{
|
||||
var currentValue = location == AvatarLocation.Member ? target.AvatarUrl : guildData?.AvatarUrl;
|
||||
var canAccess = location != AvatarLocation.Member || target.AvatarPrivacy.CanAccess(ctx.LookupContextFor(target));
|
||||
if (string.IsNullOrEmpty(currentValue) || !canAccess)
|
||||
{
|
||||
if (location == AvatarLocation.Member)
|
||||
{
|
||||
if (target.System == ctx.System?.Id)
|
||||
throw new PKSyntaxError("This member does not have an avatar set. Set one by attaching an image to this command, or by passing an image URL or @mention.");
|
||||
throw new PKError("This member does not have an avatar set.");
|
||||
}
|
||||
|
||||
if (location == AvatarLocation.Server)
|
||||
throw new PKError($"This member does not have a server avatar set. Type `pk;member {target.Reference()} avatar` to see their global avatar.");
|
||||
}
|
||||
|
||||
var field = location == AvatarLocation.Server ? $"server avatar (for {ctx.Guild.Name})" : "avatar";
|
||||
var cmd = location == AvatarLocation.Server ? "serveravatar" : "avatar";
|
||||
|
||||
var eb = new EmbedBuilder()
|
||||
.Title($"{target.NameFor(ctx)}'s {field}")
|
||||
.Image(new(currentValue?.TryGetCleanCdnUrl()));
|
||||
if (target.System == ctx.System?.Id)
|
||||
eb.Description($"To clear, use `pk;member {target.Reference()} {cmd} clear`.");
|
||||
await ctx.Reply(embed: eb.Build());
|
||||
}
|
||||
|
||||
public async Task ServerAvatar(Context ctx, PKMember target)
|
||||
{
|
||||
ctx.CheckGuildContext();
|
||||
var guildData = await _repo.GetMemberGuild(ctx.Guild.Id, target.Id);
|
||||
await AvatarCommandTree(AvatarLocation.Server, ctx, target, guildData);
|
||||
}
|
||||
|
||||
public async Task Avatar(Context ctx, PKMember target)
|
||||
{
|
||||
var guildData = ctx.Guild != null ?
|
||||
await _repo.GetMemberGuild(ctx.Guild.Id, target.Id)
|
||||
: null;
|
||||
|
||||
await AvatarCommandTree(AvatarLocation.Member, ctx, target, guildData);
|
||||
}
|
||||
|
||||
private async Task AvatarCommandTree(AvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? guildData)
|
||||
{
|
||||
// First, see if we need to *clear*
|
||||
if (await ctx.MatchClear(location == AvatarLocation.Server ? "this member's server avatar" : "this member's avatar"))
|
||||
{
|
||||
ctx.CheckSystem().CheckOwnMember(target);
|
||||
await AvatarClear(location, ctx, target, guildData);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then, parse an image from the command (from various sources...)
|
||||
var avatarArg = await ctx.MatchImage();
|
||||
if (avatarArg == null)
|
||||
{
|
||||
// If we didn't get any, just show the current avatar
|
||||
await AvatarShow(location, ctx, target, guildData);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.CheckSystem().CheckOwnMember(target);
|
||||
await AvatarUtils.VerifyAvatarOrThrow(_client, avatarArg.Value.Url);
|
||||
await UpdateAvatar(location, ctx, target, avatarArg.Value.Url);
|
||||
await PrintResponse(location, ctx, target, avatarArg.Value, guildData);
|
||||
}
|
||||
|
||||
private Task PrintResponse(AvatarLocation location, Context ctx, PKMember target, ParsedImage avatar,
|
||||
MemberGuildSettings? targetGuildData)
|
||||
{
|
||||
var typeFrag = location switch
|
||||
{
|
||||
AvatarLocation.Server => "server avatar",
|
||||
AvatarLocation.Member => "avatar",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(location))
|
||||
};
|
||||
|
||||
var serverFrag = location switch
|
||||
{
|
||||
AvatarLocation.Server => $" This avatar will now be used when proxying in this server (**{ctx.Guild.Name}**).",
|
||||
AvatarLocation.Member when targetGuildData?.AvatarUrl != null => $"\n{Emojis.Note} Note that this member *also* has a server-specific avatar set in this server (**{ctx.Guild.Name}**), and thus changing the global avatar will have no effect here.",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
var msg = avatar.Source switch
|
||||
{
|
||||
AvatarSource.User => $"{Emojis.Success} Member {typeFrag} changed to {avatar.SourceUser?.Username}'s avatar!{serverFrag}\n{Emojis.Warn} If {avatar.SourceUser?.Username} changes their avatar, the member's avatar will need to be re-set.",
|
||||
AvatarSource.Url => $"{Emojis.Success} Member {typeFrag} changed to the image at the given URL.{serverFrag}",
|
||||
AvatarSource.Attachment => $"{Emojis.Success} Member {typeFrag} changed to attached image.{serverFrag}\n{Emojis.Warn} If you delete the message containing the attachment, the avatar will stop working.",
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
|
||||
// The attachment's already right there, no need to preview it.
|
||||
var hasEmbed = avatar.Source != AvatarSource.Attachment;
|
||||
return hasEmbed
|
||||
? ctx.Reply(msg, embed: new EmbedBuilder().Image(new(avatar.Url)).Build())
|
||||
: ctx.Reply(msg);
|
||||
}
|
||||
|
||||
private Task UpdateAvatar(AvatarLocation location, Context ctx, PKMember target, string? url)
|
||||
{
|
||||
switch (location)
|
||||
{
|
||||
case AvatarLocation.Server:
|
||||
return _repo.UpdateMemberGuild(target.Id, ctx.Guild.Id, new() { AvatarUrl = url });
|
||||
case AvatarLocation.Member:
|
||||
return _repo.UpdateMember(target.Id, new() { AvatarUrl = url });
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException($"Unknown avatar location {location}");
|
||||
}
|
||||
}
|
||||
|
||||
private enum AvatarLocation
|
||||
{
|
||||
Member,
|
||||
Server
|
||||
if (mgs?.AvatarUrl != null)
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Success} Member avatar cleared. Note that this member has a server-specific avatar set here, type `pk;member {target.Reference()} serveravatar clear` if you wish to clear that too.");
|
||||
else
|
||||
await ctx.Reply($"{Emojis.Success} Member avatar cleared.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AvatarShow(AvatarLocation location, Context ctx, PKMember target,
|
||||
MemberGuildSettings? guildData)
|
||||
{
|
||||
var currentValue = location == AvatarLocation.Member ? target.AvatarUrl : guildData?.AvatarUrl;
|
||||
var canAccess = location != AvatarLocation.Member ||
|
||||
target.AvatarPrivacy.CanAccess(ctx.LookupContextFor(target));
|
||||
if (string.IsNullOrEmpty(currentValue) || !canAccess)
|
||||
{
|
||||
if (location == AvatarLocation.Member)
|
||||
{
|
||||
if (target.System == ctx.System?.Id)
|
||||
throw new PKSyntaxError(
|
||||
"This member does not have an avatar set. Set one by attaching an image to this command, or by passing an image URL or @mention.");
|
||||
throw new PKError("This member does not have an avatar set.");
|
||||
}
|
||||
|
||||
if (location == AvatarLocation.Server)
|
||||
throw new PKError(
|
||||
$"This member does not have a server avatar set. Type `pk;member {target.Reference()} avatar` to see their global avatar.");
|
||||
}
|
||||
|
||||
var field = location == AvatarLocation.Server ? $"server avatar (for {ctx.Guild.Name})" : "avatar";
|
||||
var cmd = location == AvatarLocation.Server ? "serveravatar" : "avatar";
|
||||
|
||||
var eb = new EmbedBuilder()
|
||||
.Title($"{target.NameFor(ctx)}'s {field}")
|
||||
.Image(new Embed.EmbedImage(currentValue?.TryGetCleanCdnUrl()));
|
||||
if (target.System == ctx.System?.Id)
|
||||
eb.Description($"To clear, use `pk;member {target.Reference()} {cmd} clear`.");
|
||||
await ctx.Reply(embed: eb.Build());
|
||||
}
|
||||
|
||||
public async Task ServerAvatar(Context ctx, PKMember target)
|
||||
{
|
||||
ctx.CheckGuildContext();
|
||||
var guildData = await _repo.GetMemberGuild(ctx.Guild.Id, target.Id);
|
||||
await AvatarCommandTree(AvatarLocation.Server, ctx, target, guildData);
|
||||
}
|
||||
|
||||
public async Task Avatar(Context ctx, PKMember target)
|
||||
{
|
||||
var guildData = ctx.Guild != null
|
||||
? await _repo.GetMemberGuild(ctx.Guild.Id, target.Id)
|
||||
: null;
|
||||
|
||||
await AvatarCommandTree(AvatarLocation.Member, ctx, target, guildData);
|
||||
}
|
||||
|
||||
private async Task AvatarCommandTree(AvatarLocation location, Context ctx, PKMember target,
|
||||
MemberGuildSettings? guildData)
|
||||
{
|
||||
// First, see if we need to *clear*
|
||||
if (await ctx.MatchClear(location == AvatarLocation.Server
|
||||
? "this member's server avatar"
|
||||
: "this member's avatar"))
|
||||
{
|
||||
ctx.CheckSystem().CheckOwnMember(target);
|
||||
await AvatarClear(location, ctx, target, guildData);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then, parse an image from the command (from various sources...)
|
||||
var avatarArg = await ctx.MatchImage();
|
||||
if (avatarArg == null)
|
||||
{
|
||||
// If we didn't get any, just show the current avatar
|
||||
await AvatarShow(location, ctx, target, guildData);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.CheckSystem().CheckOwnMember(target);
|
||||
await AvatarUtils.VerifyAvatarOrThrow(_client, avatarArg.Value.Url);
|
||||
await UpdateAvatar(location, ctx, target, avatarArg.Value.Url);
|
||||
await PrintResponse(location, ctx, target, avatarArg.Value, guildData);
|
||||
}
|
||||
|
||||
private Task PrintResponse(AvatarLocation location, Context ctx, PKMember target, ParsedImage avatar,
|
||||
MemberGuildSettings? targetGuildData)
|
||||
{
|
||||
var typeFrag = location switch
|
||||
{
|
||||
AvatarLocation.Server => "server avatar",
|
||||
AvatarLocation.Member => "avatar",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(location))
|
||||
};
|
||||
|
||||
var serverFrag = location switch
|
||||
{
|
||||
AvatarLocation.Server =>
|
||||
$" This avatar will now be used when proxying in this server (**{ctx.Guild.Name}**).",
|
||||
AvatarLocation.Member when targetGuildData?.AvatarUrl != null =>
|
||||
$"\n{Emojis.Note} Note that this member *also* has a server-specific avatar set in this server (**{ctx.Guild.Name}**), and thus changing the global avatar will have no effect here.",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
var msg = avatar.Source switch
|
||||
{
|
||||
AvatarSource.User =>
|
||||
$"{Emojis.Success} Member {typeFrag} changed to {avatar.SourceUser?.Username}'s avatar!{serverFrag}\n{Emojis.Warn} If {avatar.SourceUser?.Username} changes their avatar, the member's avatar will need to be re-set.",
|
||||
AvatarSource.Url =>
|
||||
$"{Emojis.Success} Member {typeFrag} changed to the image at the given URL.{serverFrag}",
|
||||
AvatarSource.Attachment =>
|
||||
$"{Emojis.Success} Member {typeFrag} changed to attached image.{serverFrag}\n{Emojis.Warn} If you delete the message containing the attachment, the avatar will stop working.",
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
|
||||
// The attachment's already right there, no need to preview it.
|
||||
var hasEmbed = avatar.Source != AvatarSource.Attachment;
|
||||
return hasEmbed
|
||||
? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(avatar.Url)).Build())
|
||||
: ctx.Reply(msg);
|
||||
}
|
||||
|
||||
private Task UpdateAvatar(AvatarLocation location, Context ctx, PKMember target, string? url)
|
||||
{
|
||||
switch (location)
|
||||
{
|
||||
case AvatarLocation.Server:
|
||||
return _repo.UpdateMemberGuild(target.Id, ctx.Guild.Id, new MemberGuildPatch { AvatarUrl = url });
|
||||
case AvatarLocation.Member:
|
||||
return _repo.UpdateMember(target.Id, new MemberPatch { AvatarUrl = url });
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException($"Unknown avatar location {location}");
|
||||
}
|
||||
}
|
||||
|
||||
private enum AvatarLocation { Member, Server }
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,87 +1,87 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Myriad.Builders;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class MemberGroup
|
||||
{
|
||||
public class MemberGroup
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
|
||||
public MemberGroup(IDatabase db, ModelRepository repo)
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public MemberGroup(IDatabase db, ModelRepository repo)
|
||||
public async Task AddRemove(Context ctx, PKMember target, Groups.AddRemoveOperation op)
|
||||
{
|
||||
ctx.CheckSystem().CheckOwnMember(target);
|
||||
|
||||
var groups = (await ctx.ParseGroupList(ctx.System.Id))
|
||||
.Select(g => g.Id)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var existingGroups = (await _repo.GetMemberGroups(target.Id).ToListAsync())
|
||||
.Select(g => g.Id)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
List<GroupId> toAction;
|
||||
|
||||
if (op == Groups.AddRemoveOperation.Add)
|
||||
{
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task AddRemove(Context ctx, PKMember target, Groups.AddRemoveOperation op)
|
||||
{
|
||||
ctx.CheckSystem().CheckOwnMember(target);
|
||||
|
||||
var groups = (await ctx.ParseGroupList(ctx.System.Id))
|
||||
.Select(g => g.Id)
|
||||
.Distinct()
|
||||
toAction = groups
|
||||
.Where(group => !existingGroups.Contains(group))
|
||||
.ToList();
|
||||
|
||||
var existingGroups = (await _repo.GetMemberGroups(target.Id).ToListAsync())
|
||||
.Select(g => g.Id)
|
||||
.Distinct()
|
||||
await _repo.AddGroupsToMember(target.Id, toAction);
|
||||
}
|
||||
else if (op == Groups.AddRemoveOperation.Remove)
|
||||
{
|
||||
toAction = groups
|
||||
.Where(group => existingGroups.Contains(group))
|
||||
.ToList();
|
||||
|
||||
List<GroupId> toAction;
|
||||
|
||||
if (op == Groups.AddRemoveOperation.Add)
|
||||
{
|
||||
toAction = groups
|
||||
.Where(group => !existingGroups.Contains(group))
|
||||
.ToList();
|
||||
|
||||
await _repo.AddGroupsToMember(target.Id, toAction);
|
||||
}
|
||||
else if (op == Groups.AddRemoveOperation.Remove)
|
||||
{
|
||||
toAction = groups
|
||||
.Where(group => existingGroups.Contains(group))
|
||||
.ToList();
|
||||
|
||||
await _repo.RemoveGroupsFromMember(target.Id, toAction);
|
||||
}
|
||||
else return; // otherwise toAction "may be unassigned"
|
||||
|
||||
await ctx.Reply(GroupMemberUtils.GenerateResponse(op, 1, groups.Count, toAction.Count, groups.Count - toAction.Count));
|
||||
await _repo.RemoveGroupsFromMember(target.Id, toAction);
|
||||
}
|
||||
|
||||
public async Task List(Context ctx, PKMember target)
|
||||
else
|
||||
{
|
||||
var pctx = ctx.LookupContextFor(target.System);
|
||||
|
||||
var groups = await _repo.GetMemberGroups(target.Id)
|
||||
.Where(g => g.Visibility.CanAccess(pctx))
|
||||
.OrderBy(g => g.Name, StringComparer.InvariantCultureIgnoreCase)
|
||||
.ToListAsync();
|
||||
|
||||
var description = "";
|
||||
var msg = "";
|
||||
|
||||
if (groups.Count == 0)
|
||||
description = "This member has no groups.";
|
||||
else
|
||||
description = string.Join("\n", groups.Select(g => $"[`{g.Hid}`] **{g.DisplayName ?? g.Name}**"));
|
||||
|
||||
if (pctx == LookupContext.ByOwner)
|
||||
{
|
||||
msg += $"\n\nTo add this member to one or more groups, use `pk;m {target.Reference()} group add <group> [group 2] [group 3...]`";
|
||||
if (groups.Count > 0)
|
||||
msg += $"\nTo remove this member from one or more groups, use `pk;m {target.Reference()} group remove <group> [group 2] [group 3...]`";
|
||||
}
|
||||
|
||||
await ctx.Reply(msg, (new EmbedBuilder().Title($"{target.Name}'s groups").Description(description)).Build());
|
||||
return; // otherwise toAction "may be unassigned"
|
||||
}
|
||||
|
||||
await ctx.Reply(GroupMemberUtils.GenerateResponse(op, 1, groups.Count, toAction.Count,
|
||||
groups.Count - toAction.Count));
|
||||
}
|
||||
|
||||
public async Task List(Context ctx, PKMember target)
|
||||
{
|
||||
var pctx = ctx.LookupContextFor(target.System);
|
||||
|
||||
var groups = await _repo.GetMemberGroups(target.Id)
|
||||
.Where(g => g.Visibility.CanAccess(pctx))
|
||||
.OrderBy(g => g.Name, StringComparer.InvariantCultureIgnoreCase)
|
||||
.ToListAsync();
|
||||
|
||||
var description = "";
|
||||
var msg = "";
|
||||
|
||||
if (groups.Count == 0)
|
||||
description = "This member has no groups.";
|
||||
else
|
||||
description = string.Join("\n", groups.Select(g => $"[`{g.Hid}`] **{g.DisplayName ?? g.Name}**"));
|
||||
|
||||
if (pctx == LookupContext.ByOwner)
|
||||
{
|
||||
msg +=
|
||||
$"\n\nTo add this member to one or more groups, use `pk;m {target.Reference()} group add <group> [group 2] [group 3...]`";
|
||||
if (groups.Count > 0)
|
||||
msg +=
|
||||
$"\nTo remove this member from one or more groups, use `pk;m {target.Reference()} group remove <group> [group 2] [group 3...]`";
|
||||
}
|
||||
|
||||
await ctx.Reply(msg, new EmbedBuilder().Title($"{target.Name}'s groups").Description(description).Build());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,137 +1,136 @@
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Dapper;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
{
|
||||
public class MemberProxy
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public MemberProxy(IDatabase db, ModelRepository repo)
|
||||
public class MemberProxy
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
|
||||
public MemberProxy(IDatabase db, ModelRepository repo)
|
||||
{
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task Proxy(Context ctx, PKMember target)
|
||||
{
|
||||
ctx.CheckSystem().CheckOwnMember(target);
|
||||
|
||||
ProxyTag ParseProxyTags(string exampleProxy)
|
||||
{
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
// // Make sure there's one and only one instance of "text" in the example proxy given
|
||||
var prefixAndSuffix = exampleProxy.Split("text");
|
||||
if (prefixAndSuffix.Length == 1) prefixAndSuffix = prefixAndSuffix[0].Split("TEXT");
|
||||
if (prefixAndSuffix.Length < 2) throw Errors.ProxyMustHaveText;
|
||||
if (prefixAndSuffix.Length > 2) throw Errors.ProxyMultipleText;
|
||||
return new ProxyTag(prefixAndSuffix[0], prefixAndSuffix[1]);
|
||||
}
|
||||
|
||||
public async Task Proxy(Context ctx, PKMember target)
|
||||
async Task<bool> WarnOnConflict(ProxyTag newTag)
|
||||
{
|
||||
ctx.CheckSystem().CheckOwnMember(target);
|
||||
var query = "select * from (select *, (unnest(proxy_tags)).prefix as prefix, (unnest(proxy_tags)).suffix as suffix from members where system = @System) as _ where prefix is not distinct from @Prefix and suffix is not distinct from @Suffix and id != @Existing";
|
||||
var conflicts = (await _db.Execute(conn => conn.QueryAsync<PKMember>(query,
|
||||
new { newTag.Prefix, newTag.Suffix, Existing = target.Id, system = target.System }))).ToList();
|
||||
|
||||
ProxyTag ParseProxyTags(string exampleProxy)
|
||||
if (conflicts.Count <= 0) return true;
|
||||
|
||||
var conflictList = conflicts.Select(m => $"- **{m.NameFor(ctx)}**");
|
||||
var msg = $"{Emojis.Warn} The following members have conflicting proxy tags:\n{string.Join('\n', conflictList)}\nDo you want to proceed anyway?";
|
||||
return await ctx.PromptYesNo(msg, "Proceed");
|
||||
}
|
||||
|
||||
// "Sub"command: clear flag
|
||||
if (await ctx.MatchClear())
|
||||
{
|
||||
// If we already have multiple tags, this would clear everything, so prompt that
|
||||
if (target.ProxyTags.Count > 1)
|
||||
{
|
||||
// // Make sure there's one and only one instance of "text" in the example proxy given
|
||||
var prefixAndSuffix = exampleProxy.Split("text");
|
||||
if (prefixAndSuffix.Length == 1) prefixAndSuffix = prefixAndSuffix[0].Split("TEXT");
|
||||
if (prefixAndSuffix.Length < 2) throw Errors.ProxyMustHaveText;
|
||||
if (prefixAndSuffix.Length > 2) throw Errors.ProxyMultipleText;
|
||||
return new ProxyTag(prefixAndSuffix[0], prefixAndSuffix[1]);
|
||||
}
|
||||
|
||||
async Task<bool> WarnOnConflict(ProxyTag newTag)
|
||||
{
|
||||
var query = "select * from (select *, (unnest(proxy_tags)).prefix as prefix, (unnest(proxy_tags)).suffix as suffix from members where system = @System) as _ where prefix is not distinct from @Prefix and suffix is not distinct from @Suffix and id != @Existing";
|
||||
var conflicts = (await _db.Execute(conn => conn.QueryAsync<PKMember>(query,
|
||||
new { Prefix = newTag.Prefix, Suffix = newTag.Suffix, Existing = target.Id, system = target.System }))).ToList();
|
||||
|
||||
if (conflicts.Count <= 0) return true;
|
||||
|
||||
var conflictList = conflicts.Select(m => $"- **{m.NameFor(ctx)}**");
|
||||
var msg = $"{Emojis.Warn} The following members have conflicting proxy tags:\n{string.Join('\n', conflictList)}\nDo you want to proceed anyway?";
|
||||
return await ctx.PromptYesNo(msg, "Proceed");
|
||||
}
|
||||
|
||||
// "Sub"command: clear flag
|
||||
if (await ctx.MatchClear())
|
||||
{
|
||||
// If we already have multiple tags, this would clear everything, so prompt that
|
||||
if (target.ProxyTags.Count > 1)
|
||||
{
|
||||
var msg = $"{Emojis.Warn} You already have multiple proxy tags set: {target.ProxyTagsString()}\nDo you want to clear them all?";
|
||||
if (!await ctx.PromptYesNo(msg, "Clear"))
|
||||
throw Errors.GenericCancelled();
|
||||
}
|
||||
|
||||
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(new ProxyTag[0]) };
|
||||
await _repo.UpdateMember(target.Id, patch);
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} Proxy tags cleared.");
|
||||
}
|
||||
// "Sub"command: no arguments; will print proxy tags
|
||||
else if (!ctx.HasNext(skipFlags: false))
|
||||
{
|
||||
if (target.ProxyTags.Count == 0)
|
||||
await ctx.Reply("This member does not have any proxy tags.");
|
||||
else
|
||||
await ctx.Reply($"This member's proxy tags are:\n{target.ProxyTagsString("\n")}");
|
||||
}
|
||||
// Subcommand: "add"
|
||||
else if (ctx.Match("add", "append"))
|
||||
{
|
||||
if (!ctx.HasNext(skipFlags: false)) throw new PKSyntaxError("You must pass an example proxy to add (eg. `[text]` or `J:text`).");
|
||||
|
||||
var tagToAdd = ParseProxyTags(ctx.RemainderOrNull(skipFlags: false));
|
||||
if (tagToAdd.IsEmpty) throw Errors.EmptyProxyTags(target);
|
||||
if (target.ProxyTags.Contains(tagToAdd))
|
||||
throw Errors.ProxyTagAlreadyExists(tagToAdd, target);
|
||||
if (tagToAdd.ProxyString.Length > Limits.MaxProxyTagLength)
|
||||
throw new PKError($"Proxy tag too long ({tagToAdd.ProxyString.Length} > {Limits.MaxProxyTagLength} characters).");
|
||||
|
||||
if (!await WarnOnConflict(tagToAdd))
|
||||
var msg = $"{Emojis.Warn} You already have multiple proxy tags set: {target.ProxyTagsString()}\nDo you want to clear them all?";
|
||||
if (!await ctx.PromptYesNo(msg, "Clear"))
|
||||
throw Errors.GenericCancelled();
|
||||
|
||||
var newTags = target.ProxyTags.ToList();
|
||||
newTags.Add(tagToAdd);
|
||||
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(newTags.ToArray()) };
|
||||
await _repo.UpdateMember(target.Id, patch);
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} Added proxy tags {tagToAdd.ProxyString.AsCode()}.");
|
||||
}
|
||||
// Subcommand: "remove"
|
||||
else if (ctx.Match("remove", "delete"))
|
||||
{
|
||||
if (!ctx.HasNext(skipFlags: false)) throw new PKSyntaxError("You must pass a proxy tag to remove (eg. `[text]` or `J:text`).");
|
||||
|
||||
var tagToRemove = ParseProxyTags(ctx.RemainderOrNull(skipFlags: false));
|
||||
if (tagToRemove.IsEmpty) throw Errors.EmptyProxyTags(target);
|
||||
if (!target.ProxyTags.Contains(tagToRemove))
|
||||
throw Errors.ProxyTagDoesNotExist(tagToRemove, target);
|
||||
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(new ProxyTag[0]) };
|
||||
await _repo.UpdateMember(target.Id, patch);
|
||||
|
||||
var newTags = target.ProxyTags.ToList();
|
||||
newTags.Remove(tagToRemove);
|
||||
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(newTags.ToArray()) };
|
||||
await _repo.UpdateMember(target.Id, patch);
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} Removed proxy tags {tagToRemove.ProxyString.AsCode()}.");
|
||||
}
|
||||
// Subcommand: bare proxy tag given
|
||||
await ctx.Reply($"{Emojis.Success} Proxy tags cleared.");
|
||||
}
|
||||
// "Sub"command: no arguments; will print proxy tags
|
||||
else if (!ctx.HasNext(false))
|
||||
{
|
||||
if (target.ProxyTags.Count == 0)
|
||||
await ctx.Reply("This member does not have any proxy tags.");
|
||||
else
|
||||
await ctx.Reply($"This member's proxy tags are:\n{target.ProxyTagsString("\n")}");
|
||||
}
|
||||
// Subcommand: "add"
|
||||
else if (ctx.Match("add", "append"))
|
||||
{
|
||||
if (!ctx.HasNext(false))
|
||||
throw new PKSyntaxError("You must pass an example proxy to add (eg. `[text]` or `J:text`).");
|
||||
|
||||
var tagToAdd = ParseProxyTags(ctx.RemainderOrNull(false));
|
||||
if (tagToAdd.IsEmpty) throw Errors.EmptyProxyTags(target);
|
||||
if (target.ProxyTags.Contains(tagToAdd))
|
||||
throw Errors.ProxyTagAlreadyExists(tagToAdd, target);
|
||||
if (tagToAdd.ProxyString.Length > Limits.MaxProxyTagLength)
|
||||
throw new PKError(
|
||||
$"Proxy tag too long ({tagToAdd.ProxyString.Length} > {Limits.MaxProxyTagLength} characters).");
|
||||
|
||||
if (!await WarnOnConflict(tagToAdd))
|
||||
throw Errors.GenericCancelled();
|
||||
|
||||
var newTags = target.ProxyTags.ToList();
|
||||
newTags.Add(tagToAdd);
|
||||
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(newTags.ToArray()) };
|
||||
await _repo.UpdateMember(target.Id, patch);
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} Added proxy tags {tagToAdd.ProxyString.AsCode()}.");
|
||||
}
|
||||
// Subcommand: "remove"
|
||||
else if (ctx.Match("remove", "delete"))
|
||||
{
|
||||
if (!ctx.HasNext(false))
|
||||
throw new PKSyntaxError("You must pass a proxy tag to remove (eg. `[text]` or `J:text`).");
|
||||
|
||||
var tagToRemove = ParseProxyTags(ctx.RemainderOrNull(false));
|
||||
if (tagToRemove.IsEmpty) throw Errors.EmptyProxyTags(target);
|
||||
if (!target.ProxyTags.Contains(tagToRemove))
|
||||
throw Errors.ProxyTagDoesNotExist(tagToRemove, target);
|
||||
|
||||
var newTags = target.ProxyTags.ToList();
|
||||
newTags.Remove(tagToRemove);
|
||||
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(newTags.ToArray()) };
|
||||
await _repo.UpdateMember(target.Id, patch);
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} Removed proxy tags {tagToRemove.ProxyString.AsCode()}.");
|
||||
}
|
||||
// Subcommand: bare proxy tag given
|
||||
else
|
||||
{
|
||||
var requestedTag = ParseProxyTags(ctx.RemainderOrNull(false));
|
||||
if (requestedTag.IsEmpty) throw Errors.EmptyProxyTags(target);
|
||||
|
||||
// This is mostly a legacy command, so it's gonna warn if there's
|
||||
// already more than one proxy tag.
|
||||
if (target.ProxyTags.Count > 1)
|
||||
{
|
||||
var requestedTag = ParseProxyTags(ctx.RemainderOrNull(skipFlags: false));
|
||||
if (requestedTag.IsEmpty) throw Errors.EmptyProxyTags(target);
|
||||
|
||||
// This is mostly a legacy command, so it's gonna warn if there's
|
||||
// already more than one proxy tag.
|
||||
if (target.ProxyTags.Count > 1)
|
||||
{
|
||||
var msg = $"This member already has more than one proxy tag set: {target.ProxyTagsString()}\nDo you want to replace them?";
|
||||
if (!await ctx.PromptYesNo(msg, "Replace"))
|
||||
throw Errors.GenericCancelled();
|
||||
}
|
||||
|
||||
if (!await WarnOnConflict(requestedTag))
|
||||
var msg = $"This member already has more than one proxy tag set: {target.ProxyTagsString()}\nDo you want to replace them?";
|
||||
if (!await ctx.PromptYesNo(msg, "Replace"))
|
||||
throw Errors.GenericCancelled();
|
||||
|
||||
var newTags = new[] { requestedTag };
|
||||
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(newTags) };
|
||||
await _repo.UpdateMember(target.Id, patch);
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} Member proxy tags set to {requestedTag.ProxyString.AsCode()}.");
|
||||
}
|
||||
|
||||
if (!await WarnOnConflict(requestedTag))
|
||||
throw Errors.GenericCancelled();
|
||||
|
||||
var newTags = new[] { requestedTag };
|
||||
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(newTags) };
|
||||
await _repo.UpdateMember(target.Id, patch);
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} Member proxy tags set to {requestedTag.ProxyString.AsCode()}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,246 +1,258 @@
|
|||
#nullable enable
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Myriad.Builders;
|
||||
using Myriad.Cache;
|
||||
using Myriad.Extensions;
|
||||
using Myriad.Rest;
|
||||
using Myriad.Rest.Exceptions;
|
||||
using Myriad.Rest.Types;
|
||||
using Myriad.Rest.Types.Requests;
|
||||
using Myriad.Rest.Exceptions;
|
||||
using Myriad.Types;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class ProxiedMessage
|
||||
{
|
||||
public class ProxiedMessage
|
||||
private static readonly Duration EditTimeout = Duration.FromMinutes(10);
|
||||
private readonly IDiscordCache _cache;
|
||||
private readonly IClock _clock;
|
||||
|
||||
private readonly IDatabase _db;
|
||||
private readonly EmbedService _embeds;
|
||||
private readonly LogChannelService _logChannel;
|
||||
private readonly ModelRepository _repo;
|
||||
private readonly DiscordApiClient _rest;
|
||||
private readonly WebhookExecutorService _webhookExecutor;
|
||||
|
||||
public ProxiedMessage(IDatabase db, ModelRepository repo, EmbedService embeds, IClock clock,
|
||||
DiscordApiClient rest,
|
||||
WebhookExecutorService webhookExecutor, LogChannelService logChannel, IDiscordCache cache)
|
||||
{
|
||||
private static readonly Duration EditTimeout = Duration.FromMinutes(10);
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
_embeds = embeds;
|
||||
_clock = clock;
|
||||
_rest = rest;
|
||||
_webhookExecutor = webhookExecutor;
|
||||
_logChannel = logChannel;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
private readonly EmbedService _embeds;
|
||||
private readonly IClock _clock;
|
||||
private readonly DiscordApiClient _rest;
|
||||
private readonly WebhookExecutorService _webhookExecutor;
|
||||
private readonly LogChannelService _logChannel;
|
||||
private readonly IDiscordCache _cache;
|
||||
public async Task EditMessage(Context ctx)
|
||||
{
|
||||
var msg = await GetMessageToEdit(ctx);
|
||||
if (!ctx.HasNext())
|
||||
throw new PKSyntaxError("You need to include the message to edit in.");
|
||||
|
||||
public ProxiedMessage(IDatabase db, ModelRepository repo, EmbedService embeds, IClock clock, DiscordApiClient rest,
|
||||
WebhookExecutorService webhookExecutor, LogChannelService logChannel, IDiscordCache cache)
|
||||
if (ctx.System.Id != msg.System.Id)
|
||||
throw new PKError("Can't edit a message sent by a different system.");
|
||||
|
||||
var newContent = ctx.RemainderOrNull().NormalizeLineEndSpacing();
|
||||
|
||||
var originalMsg = await _rest.GetMessageOrNull(msg.Message.Channel, msg.Message.Mid);
|
||||
if (originalMsg == null)
|
||||
throw new PKError("Could not edit message.");
|
||||
|
||||
try
|
||||
{
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
_embeds = embeds;
|
||||
_clock = clock;
|
||||
_rest = rest;
|
||||
_webhookExecutor = webhookExecutor;
|
||||
_logChannel = logChannel;
|
||||
_cache = cache;
|
||||
var editedMsg =
|
||||
await _webhookExecutor.EditWebhookMessage(msg.Message.Channel, msg.Message.Mid, newContent);
|
||||
|
||||
if (ctx.Guild == null)
|
||||
await _rest.CreateReaction(ctx.Channel.Id, ctx.Message.Id, new Emoji { Name = Emojis.Success });
|
||||
|
||||
if ((await ctx.BotPermissions).HasFlag(PermissionSet.ManageMessages))
|
||||
await _rest.DeleteMessage(ctx.Channel.Id, ctx.Message.Id);
|
||||
|
||||
await _logChannel.LogMessage(ctx.MessageContext, msg.Message, ctx.Message, editedMsg,
|
||||
originalMsg!.Content!);
|
||||
}
|
||||
|
||||
public async Task EditMessage(Context ctx)
|
||||
catch (NotFoundException)
|
||||
{
|
||||
var msg = await GetMessageToEdit(ctx);
|
||||
if (!ctx.HasNext())
|
||||
throw new PKSyntaxError("You need to include the message to edit in.");
|
||||
|
||||
if (ctx.System.Id != msg.System.Id)
|
||||
throw new PKError("Can't edit a message sent by a different system.");
|
||||
|
||||
var newContent = ctx.RemainderOrNull().NormalizeLineEndSpacing();
|
||||
|
||||
var originalMsg = await _rest.GetMessageOrNull(msg.Message.Channel, msg.Message.Mid);
|
||||
if (originalMsg == null)
|
||||
throw new PKError("Could not edit message.");
|
||||
|
||||
try
|
||||
{
|
||||
var editedMsg = await _webhookExecutor.EditWebhookMessage(msg.Message.Channel, msg.Message.Mid, newContent);
|
||||
|
||||
if (ctx.Guild == null)
|
||||
await _rest.CreateReaction(ctx.Channel.Id, ctx.Message.Id, new() { Name = Emojis.Success });
|
||||
|
||||
if ((await ctx.BotPermissions).HasFlag(PermissionSet.ManageMessages))
|
||||
await _rest.DeleteMessage(ctx.Channel.Id, ctx.Message.Id);
|
||||
|
||||
await _logChannel.LogMessage(ctx.MessageContext, msg.Message, ctx.Message, editedMsg, originalMsg!.Content!);
|
||||
}
|
||||
catch (NotFoundException)
|
||||
{
|
||||
throw new PKError("Could not edit message.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<FullMessage> GetMessageToEdit(Context ctx)
|
||||
{
|
||||
await using var conn = await _db.Obtain();
|
||||
FullMessage? msg = null;
|
||||
|
||||
var (referencedMessage, _) = ctx.MatchMessage(false);
|
||||
if (referencedMessage != null)
|
||||
{
|
||||
msg = await _repo.GetMessage(conn, referencedMessage.Value);
|
||||
if (msg == null)
|
||||
throw new PKError("This is not a message proxied by PluralKit.");
|
||||
}
|
||||
|
||||
if (msg == null)
|
||||
{
|
||||
if (ctx.Guild == null)
|
||||
throw new PKError("You must use a message link to edit messages in DMs.");
|
||||
|
||||
var recent = await FindRecentMessage(ctx);
|
||||
if (recent == null)
|
||||
throw new PKError("Could not find a recent message to edit.");
|
||||
|
||||
msg = await _repo.GetMessage(conn, recent.Mid);
|
||||
if (msg == null)
|
||||
throw new PKError("Could not find a recent message to edit.");
|
||||
}
|
||||
|
||||
if (msg.Message.Channel != ctx.Channel.Id)
|
||||
{
|
||||
var error = "The channel where the message was sent does not exist anymore, or you are missing permissions to access it.";
|
||||
|
||||
var channel = await _cache.GetChannel(msg.Message.Channel);
|
||||
if (channel == null)
|
||||
throw new PKError(error);
|
||||
|
||||
if (!await ctx.CheckPermissionsInGuildChannel(channel,
|
||||
PermissionSet.ViewChannel | PermissionSet.SendMessages
|
||||
))
|
||||
throw new PKError(error);
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
private async Task<PKMessage?> FindRecentMessage(Context ctx)
|
||||
{
|
||||
var lastMessage = await _repo.GetLastMessage(ctx.Guild.Id, ctx.Channel.Id, ctx.Author.Id);
|
||||
if (lastMessage == null)
|
||||
return null;
|
||||
|
||||
var timestamp = DiscordUtils.SnowflakeToInstant(lastMessage.Mid);
|
||||
if (_clock.GetCurrentInstant() - timestamp > EditTimeout)
|
||||
return null;
|
||||
|
||||
return lastMessage;
|
||||
}
|
||||
|
||||
public async Task GetMessage(Context ctx)
|
||||
{
|
||||
var (messageId, _) = ctx.MatchMessage(true);
|
||||
if (messageId == null)
|
||||
{
|
||||
if (!ctx.HasNext())
|
||||
throw new PKSyntaxError("You must pass a message ID or link.");
|
||||
throw new PKSyntaxError($"Could not parse {ctx.PeekArgument().AsCode()} as a message ID or link.");
|
||||
}
|
||||
|
||||
var isDelete = ctx.Match("delete") || ctx.MatchFlag("delete");
|
||||
|
||||
var message = await _db.Execute(c => _repo.GetMessage(c, messageId.Value));
|
||||
if (message == null)
|
||||
{
|
||||
if (isDelete)
|
||||
{
|
||||
await DeleteCommandMessage(ctx, messageId.Value);
|
||||
return;
|
||||
}
|
||||
else
|
||||
throw Errors.MessageNotFound(messageId.Value);
|
||||
}
|
||||
|
||||
var showContent = true;
|
||||
var noShowContentError = "Message deleted or inaccessible.";
|
||||
|
||||
var channel = await _cache.GetChannel(message.Message.Channel);
|
||||
if (channel == null)
|
||||
showContent = false;
|
||||
else if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
|
||||
showContent = false;
|
||||
|
||||
if (ctx.MatchRaw())
|
||||
{
|
||||
var discordMessage = await _rest.GetMessageOrNull(message.Message.Channel, message.Message.Mid);
|
||||
if (discordMessage == null || !showContent)
|
||||
throw new PKError(noShowContentError);
|
||||
|
||||
var content = discordMessage.Content;
|
||||
if (content == null || content == "")
|
||||
{
|
||||
await ctx.Reply("No message content found in that message.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Reply(text: $"```{content}```");
|
||||
|
||||
if (Regex.IsMatch(content, "```.*```", RegexOptions.Singleline))
|
||||
{
|
||||
var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
||||
await ctx.Rest.CreateMessage(
|
||||
ctx.Channel.Id,
|
||||
new MessageRequest { Content = $"{Emojis.Warn} Message contains codeblocks, raw source sent as an attachment." },
|
||||
new[] { new MultipartFile("message.txt", stream, null) });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDelete)
|
||||
{
|
||||
if (!showContent)
|
||||
throw new PKError(noShowContentError);
|
||||
|
||||
if (message.System.Id != ctx.System.Id)
|
||||
throw new PKError("You can only delete your own messages.");
|
||||
|
||||
await ctx.Rest.DeleteMessage(message.Message.Channel, message.Message.Mid);
|
||||
|
||||
if (ctx.Channel.Id == message.Message.Channel)
|
||||
await ctx.Rest.DeleteMessage(ctx.Message);
|
||||
else
|
||||
await ctx.Rest.CreateReaction(ctx.Message.ChannelId, ctx.Message.Id, new() { Name = Emojis.Success });
|
||||
|
||||
return;
|
||||
}
|
||||
if (ctx.Match("author") || ctx.MatchFlag("author"))
|
||||
{
|
||||
var user = await _cache.GetOrFetchUser(_rest, message.Message.Sender);
|
||||
var eb = new EmbedBuilder()
|
||||
.Author(new(user != null ? $"{user.Username}#{user.Discriminator}" : $"Deleted user ${message.Message.Sender}", IconUrl: user != null ? user.AvatarUrl() : null))
|
||||
.Description(message.Message.Sender.ToString());
|
||||
|
||||
await ctx.Reply(user != null ? $"{user.Mention()} ({user.Id})" : $"*(deleted user {message.Message.Sender})*", embed: eb.Build());
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message, showContent));
|
||||
}
|
||||
|
||||
private async Task DeleteCommandMessage(Context ctx, ulong messageId)
|
||||
{
|
||||
var message = await _repo.GetCommandMessage(messageId);
|
||||
if (message == null)
|
||||
throw Errors.MessageNotFound(messageId);
|
||||
|
||||
if (message.AuthorId != ctx.Author.Id)
|
||||
throw new PKError("You can only delete command messages queried by this account.");
|
||||
|
||||
await ctx.Rest.DeleteMessage(message.ChannelId, message.MessageId);
|
||||
|
||||
if (ctx.Guild != null)
|
||||
await ctx.Rest.DeleteMessage(ctx.Message);
|
||||
else
|
||||
await ctx.Rest.CreateReaction(ctx.Message.ChannelId, ctx.Message.Id, new() { Name = Emojis.Success });
|
||||
throw new PKError("Could not edit message.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<FullMessage> GetMessageToEdit(Context ctx)
|
||||
{
|
||||
await using var conn = await _db.Obtain();
|
||||
FullMessage? msg = null;
|
||||
|
||||
var (referencedMessage, _) = ctx.MatchMessage(false);
|
||||
if (referencedMessage != null)
|
||||
{
|
||||
msg = await _repo.GetMessage(conn, referencedMessage.Value);
|
||||
if (msg == null)
|
||||
throw new PKError("This is not a message proxied by PluralKit.");
|
||||
}
|
||||
|
||||
if (msg == null)
|
||||
{
|
||||
if (ctx.Guild == null)
|
||||
throw new PKError("You must use a message link to edit messages in DMs.");
|
||||
|
||||
var recent = await FindRecentMessage(ctx);
|
||||
if (recent == null)
|
||||
throw new PKError("Could not find a recent message to edit.");
|
||||
|
||||
msg = await _repo.GetMessage(conn, recent.Mid);
|
||||
if (msg == null)
|
||||
throw new PKError("Could not find a recent message to edit.");
|
||||
}
|
||||
|
||||
if (msg.Message.Channel != ctx.Channel.Id)
|
||||
{
|
||||
var error =
|
||||
"The channel where the message was sent does not exist anymore, or you are missing permissions to access it.";
|
||||
|
||||
var channel = await _cache.GetChannel(msg.Message.Channel);
|
||||
if (channel == null)
|
||||
throw new PKError(error);
|
||||
|
||||
if (!await ctx.CheckPermissionsInGuildChannel(channel,
|
||||
PermissionSet.ViewChannel | PermissionSet.SendMessages
|
||||
))
|
||||
throw new PKError(error);
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
private async Task<PKMessage?> FindRecentMessage(Context ctx)
|
||||
{
|
||||
var lastMessage = await _repo.GetLastMessage(ctx.Guild.Id, ctx.Channel.Id, ctx.Author.Id);
|
||||
if (lastMessage == null)
|
||||
return null;
|
||||
|
||||
var timestamp = DiscordUtils.SnowflakeToInstant(lastMessage.Mid);
|
||||
if (_clock.GetCurrentInstant() - timestamp > EditTimeout)
|
||||
return null;
|
||||
|
||||
return lastMessage;
|
||||
}
|
||||
|
||||
public async Task GetMessage(Context ctx)
|
||||
{
|
||||
var (messageId, _) = ctx.MatchMessage(true);
|
||||
if (messageId == null)
|
||||
{
|
||||
if (!ctx.HasNext())
|
||||
throw new PKSyntaxError("You must pass a message ID or link.");
|
||||
throw new PKSyntaxError($"Could not parse {ctx.PeekArgument().AsCode()} as a message ID or link.");
|
||||
}
|
||||
|
||||
var isDelete = ctx.Match("delete") || ctx.MatchFlag("delete");
|
||||
|
||||
var message = await _db.Execute(c => _repo.GetMessage(c, messageId.Value));
|
||||
if (message == null)
|
||||
{
|
||||
if (isDelete)
|
||||
{
|
||||
await DeleteCommandMessage(ctx, messageId.Value);
|
||||
return;
|
||||
}
|
||||
|
||||
throw Errors.MessageNotFound(messageId.Value);
|
||||
}
|
||||
|
||||
var showContent = true;
|
||||
var noShowContentError = "Message deleted or inaccessible.";
|
||||
|
||||
var channel = await _cache.GetChannel(message.Message.Channel);
|
||||
if (channel == null)
|
||||
showContent = false;
|
||||
else if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
|
||||
showContent = false;
|
||||
|
||||
if (ctx.MatchRaw())
|
||||
{
|
||||
var discordMessage = await _rest.GetMessageOrNull(message.Message.Channel, message.Message.Mid);
|
||||
if (discordMessage == null || !showContent)
|
||||
throw new PKError(noShowContentError);
|
||||
|
||||
var content = discordMessage.Content;
|
||||
if (content == null || content == "")
|
||||
{
|
||||
await ctx.Reply("No message content found in that message.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Reply($"```{content}```");
|
||||
|
||||
if (Regex.IsMatch(content, "```.*```", RegexOptions.Singleline))
|
||||
{
|
||||
var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
||||
await ctx.Rest.CreateMessage(
|
||||
ctx.Channel.Id,
|
||||
new MessageRequest
|
||||
{
|
||||
Content = $"{Emojis.Warn} Message contains codeblocks, raw source sent as an attachment."
|
||||
},
|
||||
new[] { new MultipartFile("message.txt", stream, null) });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDelete)
|
||||
{
|
||||
if (!showContent)
|
||||
throw new PKError(noShowContentError);
|
||||
|
||||
if (message.System.Id != ctx.System.Id)
|
||||
throw new PKError("You can only delete your own messages.");
|
||||
|
||||
await ctx.Rest.DeleteMessage(message.Message.Channel, message.Message.Mid);
|
||||
|
||||
if (ctx.Channel.Id == message.Message.Channel)
|
||||
await ctx.Rest.DeleteMessage(ctx.Message);
|
||||
else
|
||||
await ctx.Rest.CreateReaction(ctx.Message.ChannelId, ctx.Message.Id,
|
||||
new Emoji { Name = Emojis.Success });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.Match("author") || ctx.MatchFlag("author"))
|
||||
{
|
||||
var user = await _cache.GetOrFetchUser(_rest, message.Message.Sender);
|
||||
var eb = new EmbedBuilder()
|
||||
.Author(new Embed.EmbedAuthor(
|
||||
user != null
|
||||
? $"{user.Username}#{user.Discriminator}"
|
||||
: $"Deleted user ${message.Message.Sender}",
|
||||
IconUrl: user != null ? user.AvatarUrl() : null))
|
||||
.Description(message.Message.Sender.ToString());
|
||||
|
||||
await ctx.Reply(
|
||||
user != null ? $"{user.Mention()} ({user.Id})" : $"*(deleted user {message.Message.Sender})*",
|
||||
eb.Build());
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message, showContent));
|
||||
}
|
||||
|
||||
private async Task DeleteCommandMessage(Context ctx, ulong messageId)
|
||||
{
|
||||
var message = await _repo.GetCommandMessage(messageId);
|
||||
if (message == null)
|
||||
throw Errors.MessageNotFound(messageId);
|
||||
|
||||
if (message.AuthorId != ctx.Author.Id)
|
||||
throw new PKError("You can only delete command messages queried by this account.");
|
||||
|
||||
await ctx.Rest.DeleteMessage(message.ChannelId, message.MessageId);
|
||||
|
||||
if (ctx.Guild != null)
|
||||
await ctx.Rest.DeleteMessage(ctx.Message);
|
||||
else
|
||||
await ctx.Rest.CreateReaction(ctx.Message.ChannelId, ctx.Message.Id, new Emoji { Name = Emojis.Success });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,124 +1,134 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using App.Metrics;
|
||||
|
||||
using Humanizer;
|
||||
using Myriad.Builders;
|
||||
using Myriad.Cache;
|
||||
using Myriad.Gateway;
|
||||
using Myriad.Rest;
|
||||
using Myriad.Rest.Types.Requests;
|
||||
using Myriad.Types;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
using Myriad.Builders;
|
||||
using Myriad.Cache;
|
||||
using Myriad.Extensions;
|
||||
using Myriad.Gateway;
|
||||
using Myriad.Rest;
|
||||
using Myriad.Rest.Exceptions;
|
||||
using Myriad.Rest.Types.Requests;
|
||||
using Myriad.Types;
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
public class Misc
|
||||
{
|
||||
public class Misc
|
||||
private readonly Bot _bot;
|
||||
private readonly BotConfig _botConfig;
|
||||
private readonly IDiscordCache _cache;
|
||||
private readonly Cluster _cluster;
|
||||
private readonly CpuStatService _cpu;
|
||||
private readonly IDatabase _db;
|
||||
private readonly EmbedService _embeds;
|
||||
private readonly ProxyMatcher _matcher;
|
||||
private readonly IMetrics _metrics;
|
||||
private readonly ProxyService _proxy;
|
||||
private readonly ModelRepository _repo;
|
||||
private readonly DiscordApiClient _rest;
|
||||
private readonly ShardInfoService _shards;
|
||||
|
||||
public Misc(BotConfig botConfig, IMetrics metrics, CpuStatService cpu, ShardInfoService shards,
|
||||
EmbedService embeds, ModelRepository repo, IDatabase db, IDiscordCache cache,
|
||||
DiscordApiClient rest, Bot bot, Cluster cluster, ProxyService proxy, ProxyMatcher matcher)
|
||||
{
|
||||
private readonly BotConfig _botConfig;
|
||||
private readonly IMetrics _metrics;
|
||||
private readonly CpuStatService _cpu;
|
||||
private readonly ShardInfoService _shards;
|
||||
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;
|
||||
private readonly ProxyService _proxy;
|
||||
private readonly ProxyMatcher _matcher;
|
||||
_botConfig = botConfig;
|
||||
_metrics = metrics;
|
||||
_cpu = cpu;
|
||||
_shards = shards;
|
||||
_embeds = embeds;
|
||||
_repo = repo;
|
||||
_db = db;
|
||||
_cache = cache;
|
||||
_rest = rest;
|
||||
_bot = bot;
|
||||
_cluster = cluster;
|
||||
_proxy = proxy;
|
||||
_matcher = matcher;
|
||||
}
|
||||
|
||||
public Misc(BotConfig botConfig, IMetrics metrics, CpuStatService cpu, ShardInfoService shards, EmbedService embeds, ModelRepository repo,
|
||||
IDatabase db, IDiscordCache cache, DiscordApiClient rest, Bot bot, Cluster cluster, ProxyService proxy, ProxyMatcher matcher)
|
||||
{
|
||||
_botConfig = botConfig;
|
||||
_metrics = metrics;
|
||||
_cpu = cpu;
|
||||
_shards = shards;
|
||||
_embeds = embeds;
|
||||
_repo = repo;
|
||||
_db = db;
|
||||
_cache = cache;
|
||||
_rest = rest;
|
||||
_bot = bot;
|
||||
_cluster = cluster;
|
||||
_proxy = proxy;
|
||||
_matcher = matcher;
|
||||
}
|
||||
public async Task Invite(Context ctx)
|
||||
{
|
||||
var clientId = _botConfig.ClientId ?? await _cache.GetOwnUser();
|
||||
|
||||
public async Task Invite(Context ctx)
|
||||
{
|
||||
var clientId = _botConfig.ClientId ?? await _cache.GetOwnUser();
|
||||
var permissions =
|
||||
PermissionSet.AddReactions |
|
||||
PermissionSet.AttachFiles |
|
||||
PermissionSet.EmbedLinks |
|
||||
PermissionSet.ManageMessages |
|
||||
PermissionSet.ManageWebhooks |
|
||||
PermissionSet.ReadMessageHistory |
|
||||
PermissionSet.SendMessages;
|
||||
|
||||
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}>");
|
||||
}
|
||||
|
||||
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}>");
|
||||
}
|
||||
public async Task Stats(Context ctx)
|
||||
{
|
||||
var timeBefore = SystemClock.Instance.GetCurrentInstant();
|
||||
var msg = await ctx.Reply("...");
|
||||
var timeAfter = SystemClock.Instance.GetCurrentInstant();
|
||||
var apiLatency = timeAfter - timeBefore;
|
||||
|
||||
public async Task Stats(Context ctx)
|
||||
{
|
||||
var timeBefore = SystemClock.Instance.GetCurrentInstant();
|
||||
var msg = await ctx.Reply($"...");
|
||||
var timeAfter = SystemClock.Instance.GetCurrentInstant();
|
||||
var apiLatency = timeAfter - timeBefore;
|
||||
var messagesReceived = _metrics.Snapshot.GetForContext("Bot").Meters
|
||||
.FirstOrDefault(m => m.MultidimensionalName == BotMetrics.MessagesReceived.Name)?.Value;
|
||||
var messagesProxied = _metrics.Snapshot.GetForContext("Bot").Meters
|
||||
.FirstOrDefault(m => m.MultidimensionalName == BotMetrics.MessagesProxied.Name)?.Value;
|
||||
var commandsRun = _metrics.Snapshot.GetForContext("Bot").Meters
|
||||
.FirstOrDefault(m => m.MultidimensionalName == BotMetrics.CommandsRun.Name)?.Value;
|
||||
|
||||
var messagesReceived = _metrics.Snapshot.GetForContext("Bot").Meters.FirstOrDefault(m => m.MultidimensionalName == BotMetrics.MessagesReceived.Name)?.Value;
|
||||
var messagesProxied = _metrics.Snapshot.GetForContext("Bot").Meters.FirstOrDefault(m => m.MultidimensionalName == BotMetrics.MessagesProxied.Name)?.Value;
|
||||
var commandsRun = _metrics.Snapshot.GetForContext("Bot").Meters.FirstOrDefault(m => m.MultidimensionalName == BotMetrics.CommandsRun.Name)?.Value;
|
||||
var counts = await _repo.GetStats();
|
||||
|
||||
var counts = await _repo.GetStats();
|
||||
var shardId = ctx.Shard.ShardId;
|
||||
var shardTotal = ctx.Cluster.Shards.Count;
|
||||
var shardUpTotal = _shards.Shards.Where(x => x.Connected).Count();
|
||||
var shardInfo = _shards.GetShardInfo(ctx.Shard);
|
||||
|
||||
var shardId = ctx.Shard.ShardId;
|
||||
var shardTotal = ctx.Cluster.Shards.Count;
|
||||
var shardUpTotal = _shards.Shards.Where(x => x.Connected).Count();
|
||||
var shardInfo = _shards.GetShardInfo(ctx.Shard);
|
||||
var process = Process.GetCurrentProcess();
|
||||
var memoryUsage = process.WorkingSet64;
|
||||
|
||||
var process = Process.GetCurrentProcess();
|
||||
var memoryUsage = process.WorkingSet64;
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var shardUptime = now - shardInfo.LastConnectionTime;
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var shardUptime = now - shardInfo.LastConnectionTime;
|
||||
|
||||
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
|
||||
.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", $"{counts.SystemCount:N0} systems,"
|
||||
+ $" {counts.MemberCount:N0} members,"
|
||||
+ $" {counts.GroupCount:N0} groups,"
|
||||
+ $" {counts.SwitchCount:N0} switches,"
|
||||
+ $" {counts.MessageCount:N0} messages"))
|
||||
.Timestamp(process.StartTime.ToString("O"))
|
||||
.Footer(new($"PluralKit {BuildInfoService.Version} • https://github.com/xSke/PluralKit • Last restarted: ")); ;
|
||||
await ctx.Rest.EditMessage(msg.ChannelId, msg.Id,
|
||||
new MessageEditRequest { Content = "", Embed = embed.Build() });
|
||||
}
|
||||
var embed = new EmbedBuilder();
|
||||
if (messagesReceived != null)
|
||||
embed.Field(new Embed.Field("Messages processed",
|
||||
$"{messagesReceived.OneMinuteRate * 60:F1}/m ({messagesReceived.FifteenMinuteRate * 60:F1}/m over 15m)",
|
||||
true));
|
||||
if (messagesProxied != null)
|
||||
embed.Field(new Embed.Field("Messages proxied",
|
||||
$"{messagesProxied.OneMinuteRate * 60:F1}/m ({messagesProxied.FifteenMinuteRate * 60:F1}/m over 15m)",
|
||||
true));
|
||||
if (commandsRun != null)
|
||||
embed.Field(new Embed.Field("Commands executed",
|
||||
$"{commandsRun.OneMinuteRate * 60:F1}/m ({commandsRun.FifteenMinuteRate * 60:F1}/m over 15m)",
|
||||
true));
|
||||
|
||||
embed
|
||||
.Field(new Embed.Field("Current shard",
|
||||
$"Shard #{shardId} (of {shardTotal} total, {shardUpTotal} are up)", true))
|
||||
.Field(new Embed.Field("Shard uptime",
|
||||
$"{shardUptime.FormatDuration()} ({shardInfo.DisconnectionCount} disconnections)", true))
|
||||
.Field(new Embed.Field("CPU usage", $"{_cpu.LastCpuMeasure:P1}", true))
|
||||
.Field(new Embed.Field("Memory usage", $"{memoryUsage / 1024 / 1024} MiB", true))
|
||||
.Field(new Embed.Field("Latency",
|
||||
$"API: {apiLatency.TotalMilliseconds:F0} ms, shard: {shardInfo.ShardLatency.Milliseconds} ms",
|
||||
true))
|
||||
.Field(new Embed.Field("Total numbers", $" {counts.SystemCount:N0} systems,"
|
||||
+ $" {counts.MemberCount:N0} members,"
|
||||
+ $" {counts.GroupCount:N0} groups,"
|
||||
+ $" {counts.SwitchCount:N0} switches,"
|
||||
+ $" {counts.MessageCount:N0} messages"))
|
||||
.Timestamp(process.StartTime.ToString("O"))
|
||||
.Footer(new Embed.EmbedFooter(
|
||||
$"PluralKit {BuildInfoService.Version} • https://github.com/xSke/PluralKit • Last restarted: "));
|
||||
;
|
||||
await ctx.Rest.EditMessage(msg.ChannelId, msg.Id,
|
||||
new MessageEditRequest { Content = "", Embed = embed.Build() });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +1,60 @@
|
|||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public static class ContextPrivacyExt
|
||||
{
|
||||
public static class ContextPrivacyExt
|
||||
public static PrivacyLevel PopPrivacyLevel(this Context ctx)
|
||||
{
|
||||
public static PrivacyLevel PopPrivacyLevel(this Context ctx)
|
||||
{
|
||||
if (ctx.Match("public", "show", "shown", "visible"))
|
||||
return PrivacyLevel.Public;
|
||||
if (ctx.Match("public", "show", "shown", "visible"))
|
||||
return PrivacyLevel.Public;
|
||||
|
||||
if (ctx.Match("private", "hide", "hidden"))
|
||||
return PrivacyLevel.Private;
|
||||
if (ctx.Match("private", "hide", "hidden"))
|
||||
return PrivacyLevel.Private;
|
||||
|
||||
if (!ctx.HasNext())
|
||||
throw new PKSyntaxError("You must pass a privacy level (`public` or `private`)");
|
||||
if (!ctx.HasNext())
|
||||
throw new PKSyntaxError("You must pass a privacy level (`public` or `private`)");
|
||||
|
||||
throw new PKSyntaxError($"Invalid privacy level {ctx.PopArgument().AsCode()} (must be `public` or `private`).");
|
||||
}
|
||||
throw new PKSyntaxError(
|
||||
$"Invalid privacy level {ctx.PopArgument().AsCode()} (must be `public` or `private`).");
|
||||
}
|
||||
|
||||
public static SystemPrivacySubject PopSystemPrivacySubject(this Context ctx)
|
||||
{
|
||||
if (!SystemPrivacyUtils.TryParseSystemPrivacy(ctx.PeekArgument(), out var subject))
|
||||
throw new PKSyntaxError($"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `description`, `members`, `front`, `fronthistory`, `groups`, or `all`).");
|
||||
public static SystemPrivacySubject PopSystemPrivacySubject(this Context ctx)
|
||||
{
|
||||
if (!SystemPrivacyUtils.TryParseSystemPrivacy(ctx.PeekArgument(), out var subject))
|
||||
throw new PKSyntaxError(
|
||||
$"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `description`, `members`, `front`, `fronthistory`, `groups`, or `all`).");
|
||||
|
||||
ctx.PopArgument();
|
||||
return subject;
|
||||
}
|
||||
ctx.PopArgument();
|
||||
return subject;
|
||||
}
|
||||
|
||||
public static MemberPrivacySubject PopMemberPrivacySubject(this Context ctx)
|
||||
{
|
||||
if (!MemberPrivacyUtils.TryParseMemberPrivacy(ctx.PeekArgument(), out var subject))
|
||||
throw new PKSyntaxError($"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `name`, `description`, `avatar`, `birthday`, `pronouns`, `metadata`, `visibility`, or `all`).");
|
||||
public static MemberPrivacySubject PopMemberPrivacySubject(this Context ctx)
|
||||
{
|
||||
if (!MemberPrivacyUtils.TryParseMemberPrivacy(ctx.PeekArgument(), out var subject))
|
||||
throw new PKSyntaxError(
|
||||
$"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `name`, `description`, `avatar`, `birthday`, `pronouns`, `metadata`, `visibility`, or `all`).");
|
||||
|
||||
ctx.PopArgument();
|
||||
return subject;
|
||||
}
|
||||
ctx.PopArgument();
|
||||
return subject;
|
||||
}
|
||||
|
||||
public static GroupPrivacySubject PopGroupPrivacySubject(this Context ctx)
|
||||
{
|
||||
if (!GroupPrivacyUtils.TryParseGroupPrivacy(ctx.PeekArgument(), out var subject))
|
||||
throw new PKSyntaxError($"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `description`, `icon`, `visibility`, or `all`).");
|
||||
public static GroupPrivacySubject PopGroupPrivacySubject(this Context ctx)
|
||||
{
|
||||
if (!GroupPrivacyUtils.TryParseGroupPrivacy(ctx.PeekArgument(), out var subject))
|
||||
throw new PKSyntaxError(
|
||||
$"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `description`, `icon`, `visibility`, or `all`).");
|
||||
|
||||
ctx.PopArgument();
|
||||
return subject;
|
||||
}
|
||||
ctx.PopArgument();
|
||||
return subject;
|
||||
}
|
||||
|
||||
public static bool MatchPrivateFlag(this Context ctx, LookupContext pctx)
|
||||
{
|
||||
var privacy = true;
|
||||
if (ctx.MatchFlag("a", "all")) privacy = false;
|
||||
if (pctx == LookupContext.ByNonOwner && !privacy) throw Errors.LookupNotAllowed;
|
||||
public static bool MatchPrivateFlag(this Context ctx, LookupContext pctx)
|
||||
{
|
||||
var privacy = true;
|
||||
if (ctx.MatchFlag("a", "all")) privacy = false;
|
||||
if (pctx == LookupContext.ByNonOwner && !privacy) throw Errors.LookupNotAllowed;
|
||||
|
||||
return privacy;
|
||||
}
|
||||
return privacy;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,77 +1,77 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class Random
|
||||
{
|
||||
public class Random
|
||||
private readonly IDatabase _db;
|
||||
private readonly EmbedService _embeds;
|
||||
private readonly ModelRepository _repo;
|
||||
|
||||
private readonly global::System.Random randGen = new();
|
||||
|
||||
public Random(EmbedService embeds, IDatabase db, ModelRepository repo)
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
private readonly EmbedService _embeds;
|
||||
_embeds = embeds;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
private readonly global::System.Random randGen = new global::System.Random();
|
||||
// todo: get postgresql to return one random member/group instead of querying all members/groups
|
||||
|
||||
public Random(EmbedService embeds, IDatabase db, ModelRepository repo)
|
||||
{
|
||||
_embeds = embeds;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
public async Task Member(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
// todo: get postgresql to return one random member/group instead of querying all members/groups
|
||||
var members = await _repo.GetSystemMembers(ctx.System.Id).ToListAsync();
|
||||
|
||||
public async Task Member(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
if (!ctx.MatchFlag("all", "a"))
|
||||
members = members.Where(m => m.MemberVisibility == PrivacyLevel.Public).ToList();
|
||||
|
||||
var members = await _repo.GetSystemMembers(ctx.System.Id).ToListAsync();
|
||||
if (members == null || !members.Any())
|
||||
throw new PKError(
|
||||
"Your system has no members! Please create at least one member before using this command.");
|
||||
|
||||
if (!ctx.MatchFlag("all", "a"))
|
||||
members = members.Where(m => m.MemberVisibility == PrivacyLevel.Public).ToList();
|
||||
var randInt = randGen.Next(members.Count);
|
||||
await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild,
|
||||
ctx.LookupContextFor(ctx.System)));
|
||||
}
|
||||
|
||||
if (members == null || !members.Any())
|
||||
throw new PKError("Your system has no members! Please create at least one member before using this command.");
|
||||
public async Task Group(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
var randInt = randGen.Next(members.Count);
|
||||
await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild, ctx.LookupContextFor(ctx.System)));
|
||||
}
|
||||
var groups = await _db.Execute(c => c.QueryGroupList(ctx.System.Id));
|
||||
if (!ctx.MatchFlag("all", "a"))
|
||||
groups = groups.Where(g => g.Visibility == PrivacyLevel.Public);
|
||||
|
||||
public async Task Group(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
if (groups == null || !groups.Any())
|
||||
throw new PKError(
|
||||
"Your system has no groups! Please create at least one group before using this command.");
|
||||
|
||||
var groups = await _db.Execute(c => c.QueryGroupList(ctx.System.Id));
|
||||
if (!ctx.MatchFlag("all", "a"))
|
||||
groups = groups.Where(g => g.Visibility == PrivacyLevel.Public);
|
||||
var randInt = randGen.Next(groups.Count());
|
||||
await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, ctx.System, groups.ToArray()[randInt]));
|
||||
}
|
||||
|
||||
if (groups == null || !groups.Any())
|
||||
throw new PKError("Your system has no groups! Please create at least one group before using this command.");
|
||||
public async Task GroupMember(Context ctx, PKGroup group)
|
||||
{
|
||||
var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(group.System));
|
||||
opts.GroupFilter = group.Id;
|
||||
|
||||
var randInt = randGen.Next(groups.Count());
|
||||
await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, ctx.System, groups.ToArray()[randInt]));
|
||||
}
|
||||
await using var conn = await _db.Obtain();
|
||||
var members = await conn.QueryMemberList(ctx.System.Id, opts.ToQueryOptions());
|
||||
|
||||
public async Task GroupMember(Context ctx, PKGroup group)
|
||||
{
|
||||
var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(group.System));
|
||||
opts.GroupFilter = group.Id;
|
||||
if (members == null || !members.Any())
|
||||
throw new PKError(
|
||||
"This group has no members! Please add at least one member to this group before using this command.");
|
||||
|
||||
await using var conn = await _db.Obtain();
|
||||
var members = await conn.QueryMemberList(ctx.System.Id, opts.ToQueryOptions());
|
||||
if (!ctx.MatchFlag("all", "a"))
|
||||
members = members.Where(g => g.MemberVisibility == PrivacyLevel.Public);
|
||||
|
||||
if (members == null || !members.Any())
|
||||
throw new PKError("This group has no members! Please add at least one member to this group before using this command.");
|
||||
var ms = members.ToList();
|
||||
|
||||
if (!ctx.MatchFlag("all", "a"))
|
||||
members = members.Where(g => g.MemberVisibility == PrivacyLevel.Public);
|
||||
|
||||
var ms = members.ToList();
|
||||
|
||||
var randInt = randGen.Next(ms.Count);
|
||||
await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, ms[randInt], ctx.Guild, ctx.LookupContextFor(ctx.System)));
|
||||
}
|
||||
var randInt = randGen.Next(ms.Count);
|
||||
await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, ms[randInt], ctx.Guild,
|
||||
ctx.LookupContextFor(ctx.System)));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,4 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Myriad.Builders;
|
||||
using Myriad.Cache;
|
||||
|
|
@ -10,206 +7,224 @@ using Myriad.Types;
|
|||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class ServerConfig
|
||||
{
|
||||
public class ServerConfig
|
||||
private readonly Bot _bot;
|
||||
private readonly IDiscordCache _cache;
|
||||
private readonly LoggerCleanService _cleanService;
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
|
||||
public ServerConfig(LoggerCleanService cleanService, IDatabase db, ModelRepository repo, IDiscordCache cache,
|
||||
Bot bot)
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
private readonly IDiscordCache _cache;
|
||||
private readonly LoggerCleanService _cleanService;
|
||||
private readonly Bot _bot;
|
||||
public ServerConfig(LoggerCleanService cleanService, IDatabase db, ModelRepository repo, IDiscordCache cache, Bot bot)
|
||||
_cleanService = cleanService;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
_cache = cache;
|
||||
_bot = bot;
|
||||
}
|
||||
|
||||
public async Task SetLogChannel(Context ctx)
|
||||
{
|
||||
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
|
||||
var settings = await _repo.GetGuild(ctx.Guild.Id);
|
||||
|
||||
if (await ctx.MatchClear("the server log channel"))
|
||||
{
|
||||
_cleanService = cleanService;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
_cache = cache;
|
||||
_bot = bot;
|
||||
await _repo.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogChannel = null });
|
||||
await ctx.Reply($"{Emojis.Success} Proxy logging channel cleared.");
|
||||
return;
|
||||
}
|
||||
|
||||
public async Task SetLogChannel(Context ctx)
|
||||
if (!ctx.HasNext())
|
||||
{
|
||||
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
|
||||
var settings = await _repo.GetGuild(ctx.Guild.Id);
|
||||
|
||||
if (await ctx.MatchClear("the server log channel"))
|
||||
if (settings.LogChannel == null)
|
||||
{
|
||||
await _repo.UpdateGuild(ctx.Guild.Id, new() { LogChannel = null });
|
||||
await ctx.Reply($"{Emojis.Success} Proxy logging channel cleared.");
|
||||
await ctx.Reply("This server does not have a log channel set.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.HasNext())
|
||||
{
|
||||
if (settings.LogChannel == null)
|
||||
{
|
||||
await ctx.Reply("This server does not have a log channel set.");
|
||||
return;
|
||||
}
|
||||
await ctx.Reply($"This server's log channel is currently set to <#{settings.LogChannel}>.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Reply($"This server's log channel is currently set to <#{settings.LogChannel}>.");
|
||||
return;
|
||||
Channel channel = null;
|
||||
var channelString = ctx.PeekArgument();
|
||||
channel = await ctx.MatchChannel();
|
||||
if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString);
|
||||
if (channel.Type != Channel.ChannelType.GuildText)
|
||||
throw new PKError("PluralKit cannot log messages to this type of channel.");
|
||||
|
||||
var perms = await _cache.PermissionsIn(channel.Id);
|
||||
if (!perms.HasFlag(PermissionSet.SendMessages))
|
||||
throw new PKError("PluralKit is missing **Send Messages** permissions in the new log channel.");
|
||||
if (!perms.HasFlag(PermissionSet.EmbedLinks))
|
||||
throw new PKError("PluralKit is missing **Embed Links** permissions in the new log channel.");
|
||||
|
||||
await _repo.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogChannel = channel.Id });
|
||||
await ctx.Reply($"{Emojis.Success} Proxy logging channel set to <#{channel.Id}>.");
|
||||
}
|
||||
|
||||
public async Task SetLogEnabled(Context ctx, bool enable)
|
||||
{
|
||||
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
|
||||
|
||||
var affectedChannels = new List<Channel>();
|
||||
if (ctx.Match("all"))
|
||||
affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id))
|
||||
.Where(x => x.Type == Channel.ChannelType.GuildText).ToList();
|
||||
else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels.");
|
||||
else
|
||||
while (ctx.HasNext())
|
||||
{
|
||||
var channelString = ctx.PeekArgument();
|
||||
var channel = await ctx.MatchChannel();
|
||||
if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString);
|
||||
affectedChannels.Add(channel);
|
||||
}
|
||||
|
||||
Channel channel = null;
|
||||
var channelString = ctx.PeekArgument();
|
||||
channel = await ctx.MatchChannel();
|
||||
if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString);
|
||||
if (channel.Type != Channel.ChannelType.GuildText)
|
||||
throw new PKError("PluralKit cannot log messages to this type of channel.");
|
||||
ulong? logChannel = null;
|
||||
var config = await _repo.GetGuild(ctx.Guild.Id);
|
||||
logChannel = config.LogChannel;
|
||||
|
||||
var perms = await _cache.PermissionsIn(channel.Id);
|
||||
if (!perms.HasFlag(PermissionSet.SendMessages))
|
||||
throw new PKError("PluralKit is missing **Send Messages** permissions in the new log channel.");
|
||||
if (!perms.HasFlag(PermissionSet.EmbedLinks))
|
||||
throw new PKError("PluralKit is missing **Embed Links** permissions in the new log channel.");
|
||||
var blacklist = config.LogBlacklist.ToHashSet();
|
||||
if (enable)
|
||||
blacklist.ExceptWith(affectedChannels.Select(c => c.Id));
|
||||
else
|
||||
blacklist.UnionWith(affectedChannels.Select(c => c.Id));
|
||||
|
||||
await _repo.UpdateGuild(ctx.Guild.Id, new() { LogChannel = channel.Id });
|
||||
await ctx.Reply($"{Emojis.Success} Proxy logging channel set to <#{channel.Id}>.");
|
||||
}
|
||||
await _repo.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogBlacklist = blacklist.ToArray() });
|
||||
|
||||
public async Task SetLogEnabled(Context ctx, bool enable)
|
||||
{
|
||||
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Success} Message logging for the given channels {(enable ? "enabled" : "disabled")}." +
|
||||
(logChannel == null
|
||||
? $"\n{Emojis.Warn} Please note that no logging channel is set, so there is nowhere to log messages to. You can set a logging channel using `pk;log channel #your-log-channel`."
|
||||
: ""));
|
||||
}
|
||||
|
||||
var affectedChannels = new List<Channel>();
|
||||
if (ctx.Match("all"))
|
||||
affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id)).Where(x => x.Type == Channel.ChannelType.GuildText).ToList();
|
||||
else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels.");
|
||||
else while (ctx.HasNext())
|
||||
{
|
||||
var channelString = ctx.PeekArgument();
|
||||
var channel = await ctx.MatchChannel();
|
||||
if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString);
|
||||
affectedChannels.Add(channel);
|
||||
}
|
||||
public async Task ShowBlacklisted(Context ctx)
|
||||
{
|
||||
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
|
||||
|
||||
ulong? logChannel = null;
|
||||
var config = await _repo.GetGuild(ctx.Guild.Id);
|
||||
logChannel = config.LogChannel;
|
||||
var blacklist = await _repo.GetGuild(ctx.Guild.Id);
|
||||
|
||||
var blacklist = config.LogBlacklist.ToHashSet();
|
||||
if (enable)
|
||||
blacklist.ExceptWith(affectedChannels.Select(c => c.Id));
|
||||
else
|
||||
blacklist.UnionWith(affectedChannels.Select(c => c.Id));
|
||||
|
||||
await _repo.UpdateGuild(ctx.Guild.Id, new() { LogBlacklist = blacklist.ToArray() });
|
||||
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Success} Message logging for the given channels {(enable ? "enabled" : "disabled")}." +
|
||||
(logChannel == null ? $"\n{Emojis.Warn} Please note that no logging channel is set, so there is nowhere to log messages to. You can set a logging channel using `pk;log channel #your-log-channel`." : ""));
|
||||
}
|
||||
|
||||
public async Task ShowBlacklisted(Context ctx)
|
||||
{
|
||||
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
|
||||
|
||||
var blacklist = await _repo.GetGuild(ctx.Guild.Id);
|
||||
|
||||
// Resolve all channels from the cache and order by position
|
||||
var channels = (await Task.WhenAll(blacklist.Blacklist
|
||||
// Resolve all channels from the cache and order by position
|
||||
var channels = (await Task.WhenAll(blacklist.Blacklist
|
||||
.Select(id => _cache.TryGetChannel(id))))
|
||||
.Where(c => c != null)
|
||||
.OrderBy(c => c.Position)
|
||||
.ToList();
|
||||
.Where(c => c != null)
|
||||
.OrderBy(c => c.Position)
|
||||
.ToList();
|
||||
|
||||
if (channels.Count == 0)
|
||||
if (channels.Count == 0)
|
||||
{
|
||||
await ctx.Reply("This server has no blacklisted channels.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Paginate(channels.ToAsyncEnumerable(), channels.Count, 25,
|
||||
$"Blacklisted channels for {ctx.Guild.Name}",
|
||||
null,
|
||||
async (eb, l) =>
|
||||
{
|
||||
await ctx.Reply($"This server has no blacklisted channels.");
|
||||
return;
|
||||
}
|
||||
async Task<string> CategoryName(ulong? id) =>
|
||||
id != null ? (await _cache.GetChannel(id.Value)).Name : "(no category)";
|
||||
|
||||
await ctx.Paginate(channels.ToAsyncEnumerable(), channels.Count, 25,
|
||||
$"Blacklisted channels for {ctx.Guild.Name}",
|
||||
null,
|
||||
async (eb, l) =>
|
||||
ulong? lastCategory = null;
|
||||
|
||||
var fieldValue = new StringBuilder();
|
||||
foreach (var channel in l)
|
||||
{
|
||||
async Task<string> CategoryName(ulong? id) =>
|
||||
id != null ? (await _cache.GetChannel(id.Value)).Name : "(no category)";
|
||||
|
||||
ulong? lastCategory = null;
|
||||
|
||||
var fieldValue = new StringBuilder();
|
||||
foreach (var channel in l)
|
||||
if (lastCategory != channel!.ParentId && fieldValue.Length > 0)
|
||||
{
|
||||
if (lastCategory != channel!.ParentId && fieldValue.Length > 0)
|
||||
{
|
||||
eb.Field(new(await CategoryName(lastCategory), fieldValue.ToString()));
|
||||
fieldValue.Clear();
|
||||
}
|
||||
else fieldValue.Append("\n");
|
||||
|
||||
fieldValue.Append(channel.Mention());
|
||||
lastCategory = channel.ParentId;
|
||||
eb.Field(new Embed.Field(await CategoryName(lastCategory), fieldValue.ToString()));
|
||||
fieldValue.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
fieldValue.Append("\n");
|
||||
}
|
||||
|
||||
eb.Field(new(await CategoryName(lastCategory), fieldValue.ToString()));
|
||||
});
|
||||
}
|
||||
|
||||
public async Task SetBlacklisted(Context ctx, bool shouldAdd)
|
||||
{
|
||||
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
|
||||
|
||||
var affectedChannels = new List<Channel>();
|
||||
if (ctx.Match("all"))
|
||||
affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id)).Where(x => x.Type == Channel.ChannelType.GuildText).ToList();
|
||||
else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels.");
|
||||
else while (ctx.HasNext())
|
||||
{
|
||||
var channelString = ctx.PeekArgument();
|
||||
var channel = await ctx.MatchChannel();
|
||||
if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString);
|
||||
affectedChannels.Add(channel);
|
||||
fieldValue.Append(channel.Mention());
|
||||
lastCategory = channel.ParentId;
|
||||
}
|
||||
|
||||
var guild = await _repo.GetGuild(ctx.Guild.Id);
|
||||
eb.Field(new Embed.Field(await CategoryName(lastCategory), fieldValue.ToString()));
|
||||
});
|
||||
}
|
||||
|
||||
var blacklist = guild.Blacklist.ToHashSet();
|
||||
if (shouldAdd)
|
||||
blacklist.UnionWith(affectedChannels.Select(c => c.Id));
|
||||
else
|
||||
blacklist.ExceptWith(affectedChannels.Select(c => c.Id));
|
||||
public async Task SetBlacklisted(Context ctx, bool shouldAdd)
|
||||
{
|
||||
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
|
||||
|
||||
await _repo.UpdateGuild(ctx.Guild.Id, new() { Blacklist = blacklist.ToArray() });
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} Channels {(shouldAdd ? "added to" : "removed from")} the proxy blacklist.");
|
||||
}
|
||||
|
||||
public async Task SetLogCleanup(Context ctx)
|
||||
{
|
||||
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
|
||||
|
||||
var botList = string.Join(", ", _cleanService.Bots.Select(b => b.Name).OrderBy(x => x.ToLowerInvariant()));
|
||||
|
||||
bool newValue;
|
||||
if (ctx.Match("enable", "on", "yes"))
|
||||
newValue = true;
|
||||
else if (ctx.Match("disable", "off", "no"))
|
||||
newValue = false;
|
||||
else
|
||||
var affectedChannels = new List<Channel>();
|
||||
if (ctx.Match("all"))
|
||||
affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id))
|
||||
.Where(x => x.Type == Channel.ChannelType.GuildText).ToList();
|
||||
else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels.");
|
||||
else
|
||||
while (ctx.HasNext())
|
||||
{
|
||||
var eb = new EmbedBuilder()
|
||||
.Title("Log cleanup settings")
|
||||
.Field(new("Supported bots", botList));
|
||||
|
||||
var guildCfg = await _repo.GetGuild(ctx.Guild.Id);
|
||||
if (guildCfg.LogCleanupEnabled)
|
||||
eb.Description("Log cleanup is currently **on** for this server. To disable it, type `pk;logclean off`.");
|
||||
else
|
||||
eb.Description("Log cleanup is currently **off** for this server. To enable it, type `pk;logclean on`.");
|
||||
await ctx.Reply(embed: eb.Build());
|
||||
return;
|
||||
var channelString = ctx.PeekArgument();
|
||||
var channel = await ctx.MatchChannel();
|
||||
if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString);
|
||||
affectedChannels.Add(channel);
|
||||
}
|
||||
|
||||
await _repo.UpdateGuild(ctx.Guild.Id, new() { LogCleanupEnabled = newValue });
|
||||
var guild = await _repo.GetGuild(ctx.Guild.Id);
|
||||
|
||||
if (newValue)
|
||||
await ctx.Reply($"{Emojis.Success} Log cleanup has been **enabled** for this server. Messages deleted by PluralKit will now be cleaned up from logging channels managed by the following bots:\n- **{botList}**\n\n{Emojis.Note} Make sure PluralKit has the **Manage Messages** permission in the channels in question.\n{Emojis.Note} Also, make sure to blacklist the logging channel itself from the bots in question to prevent conflicts.");
|
||||
else
|
||||
await ctx.Reply($"{Emojis.Success} Log cleanup has been **disabled** for this server.");
|
||||
var blacklist = guild.Blacklist.ToHashSet();
|
||||
if (shouldAdd)
|
||||
blacklist.UnionWith(affectedChannels.Select(c => c.Id));
|
||||
else
|
||||
blacklist.ExceptWith(affectedChannels.Select(c => c.Id));
|
||||
|
||||
await _repo.UpdateGuild(ctx.Guild.Id, new GuildPatch { Blacklist = blacklist.ToArray() });
|
||||
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Success} Channels {(shouldAdd ? "added to" : "removed from")} the proxy blacklist.");
|
||||
}
|
||||
|
||||
public async Task SetLogCleanup(Context ctx)
|
||||
{
|
||||
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
|
||||
|
||||
var botList = string.Join(", ", _cleanService.Bots.Select(b => b.Name).OrderBy(x => x.ToLowerInvariant()));
|
||||
|
||||
bool newValue;
|
||||
if (ctx.Match("enable", "on", "yes"))
|
||||
{
|
||||
newValue = true;
|
||||
}
|
||||
else if (ctx.Match("disable", "off", "no"))
|
||||
{
|
||||
newValue = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
var eb = new EmbedBuilder()
|
||||
.Title("Log cleanup settings")
|
||||
.Field(new Embed.Field("Supported bots", botList));
|
||||
|
||||
var guildCfg = await _repo.GetGuild(ctx.Guild.Id);
|
||||
if (guildCfg.LogCleanupEnabled)
|
||||
eb.Description(
|
||||
"Log cleanup is currently **on** for this server. To disable it, type `pk;logclean off`.");
|
||||
else
|
||||
eb.Description(
|
||||
"Log cleanup is currently **off** for this server. To enable it, type `pk;logclean on`.");
|
||||
await ctx.Reply(embed: eb.Build());
|
||||
return;
|
||||
}
|
||||
|
||||
await _repo.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogCleanupEnabled = newValue });
|
||||
|
||||
if (newValue)
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Success} Log cleanup has been **enabled** for this server. Messages deleted by PluralKit will now be cleaned up from logging channels managed by the following bots:\n- **{botList}**\n\n{Emojis.Note} Make sure PluralKit has the **Manage Messages** permission in the channels in question.\n{Emojis.Note} Also, make sure to blacklist the logging channel itself from the bots in question to prevent conflicts.");
|
||||
else
|
||||
await ctx.Reply($"{Emojis.Success} Log cleanup has been **disabled** for this server.");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,204 +1,212 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using NodaTime;
|
||||
using NodaTime.TimeZones;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class Switch
|
||||
{
|
||||
public class Switch
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
|
||||
public Switch(IDatabase db, ModelRepository repo)
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public Switch(IDatabase db, ModelRepository repo)
|
||||
public async Task SwitchDo(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
var members = await ctx.ParseMemberList(ctx.System.Id);
|
||||
await DoSwitchCommand(ctx, members);
|
||||
}
|
||||
|
||||
public async Task SwitchOut(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
// Switch with no members = switch-out
|
||||
await DoSwitchCommand(ctx, new PKMember[] { });
|
||||
}
|
||||
|
||||
private async Task DoSwitchCommand(Context ctx, ICollection<PKMember> members)
|
||||
{
|
||||
// Make sure there are no dupes in the list
|
||||
// We do this by checking if removing duplicate member IDs results in a list of different length
|
||||
if (members.Select(m => m.Id).Distinct().Count() != members.Count) throw Errors.DuplicateSwitchMembers;
|
||||
if (members.Count > Limits.MaxSwitchMemberCount)
|
||||
throw new PKError(
|
||||
$"Switch contains too many members ({members.Count} > {Limits.MaxSwitchMemberCount} members).");
|
||||
|
||||
// Find the last switch and its members if applicable
|
||||
await using var conn = await _db.Obtain();
|
||||
var lastSwitch = await _repo.GetLatestSwitch(ctx.System.Id);
|
||||
if (lastSwitch != null)
|
||||
{
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task SwitchDo(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
var members = await ctx.ParseMemberList(ctx.System.Id);
|
||||
await DoSwitchCommand(ctx, members);
|
||||
}
|
||||
public async Task SwitchOut(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
// Switch with no members = switch-out
|
||||
await DoSwitchCommand(ctx, new PKMember[] { });
|
||||
}
|
||||
|
||||
private async Task DoSwitchCommand(Context ctx, ICollection<PKMember> members)
|
||||
{
|
||||
// Make sure there are no dupes in the list
|
||||
// We do this by checking if removing duplicate member IDs results in a list of different length
|
||||
if (members.Select(m => m.Id).Distinct().Count() != members.Count) throw Errors.DuplicateSwitchMembers;
|
||||
if (members.Count > Limits.MaxSwitchMemberCount)
|
||||
throw new PKError($"Switch contains too many members ({members.Count} > {Limits.MaxSwitchMemberCount} members).");
|
||||
|
||||
// Find the last switch and its members if applicable
|
||||
await using var conn = await _db.Obtain();
|
||||
var lastSwitch = await _repo.GetLatestSwitch(ctx.System.Id);
|
||||
if (lastSwitch != null)
|
||||
{
|
||||
var lastSwitchMembers = _repo.GetSwitchMembers(conn, lastSwitch.Id);
|
||||
// Make sure the requested switch isn't identical to the last one
|
||||
if (await lastSwitchMembers.Select(m => m.Id).SequenceEqualAsync(members.Select(m => m.Id).ToAsyncEnumerable()))
|
||||
throw Errors.SameSwitch(members, ctx.LookupContextFor(ctx.System));
|
||||
}
|
||||
|
||||
await _repo.AddSwitch(conn, ctx.System.Id, members.Select(m => m.Id).ToList());
|
||||
|
||||
if (members.Count == 0)
|
||||
await ctx.Reply($"{Emojis.Success} Switch-out registered.");
|
||||
else
|
||||
await ctx.Reply($"{Emojis.Success} Switch registered. Current fronter is now {string.Join(", ", members.Select(m => m.NameFor(ctx)))}.");
|
||||
}
|
||||
|
||||
public async Task SwitchMove(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
var timeToMove = ctx.RemainderOrNull() ?? throw new PKSyntaxError("Must pass a date or time to move the switch to.");
|
||||
var tz = TzdbDateTimeZoneSource.Default.ForId(ctx.System.UiTz ?? "UTC");
|
||||
|
||||
var result = DateUtils.ParseDateTime(timeToMove, true, tz);
|
||||
if (result == null) throw Errors.InvalidDateTime(timeToMove);
|
||||
|
||||
|
||||
var time = result.Value;
|
||||
if (time.ToInstant() > SystemClock.Instance.GetCurrentInstant()) throw Errors.SwitchTimeInFuture;
|
||||
|
||||
// Fetch the last two switches for the system to do bounds checking on
|
||||
var lastTwoSwitches = await _repo.GetSwitches(ctx.System.Id).Take(2).ToListAsync();
|
||||
|
||||
// If we don't have a switch to move, don't bother
|
||||
if (lastTwoSwitches.Count == 0) throw Errors.NoRegisteredSwitches;
|
||||
|
||||
// If there's a switch *behind* the one we move, we check to make sure we're not moving the time further back than that
|
||||
if (lastTwoSwitches.Count == 2)
|
||||
{
|
||||
if (lastTwoSwitches[1].Timestamp > time.ToInstant())
|
||||
throw Errors.SwitchMoveBeforeSecondLast(lastTwoSwitches[1].Timestamp.InZone(tz));
|
||||
}
|
||||
|
||||
// Now we can actually do the move, yay!
|
||||
// But, we do a prompt to confirm.
|
||||
var lastSwitchMembers = _db.Execute(conn => _repo.GetSwitchMembers(conn, lastTwoSwitches[0].Id));
|
||||
var lastSwitchMemberStr = string.Join(", ", await lastSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync());
|
||||
var lastSwitchTime = lastTwoSwitches[0].Timestamp.ToUnixTimeSeconds(); // .FormatZoned(ctx.System)
|
||||
var lastSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp).FormatDuration();
|
||||
var newSwitchTime = time.ToInstant().ToUnixTimeSeconds();
|
||||
var newSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - time.ToInstant()).FormatDuration();
|
||||
|
||||
// yeet
|
||||
var msg = $"{Emojis.Warn} This will move the latest switch ({lastSwitchMemberStr}) from <t:{lastSwitchTime}> ({lastSwitchDeltaStr} ago) to <t:{newSwitchTime}> ({newSwitchDeltaStr} ago). Is this OK?";
|
||||
if (!await ctx.PromptYesNo(msg, "Move Switch")) throw Errors.SwitchMoveCancelled;
|
||||
|
||||
// aaaand *now* we do the move
|
||||
await _repo.MoveSwitch(lastTwoSwitches[0].Id, time.ToInstant());
|
||||
await ctx.Reply($"{Emojis.Success} Switch moved to <t:{newSwitchTime}> ({newSwitchDeltaStr} ago).");
|
||||
}
|
||||
|
||||
public async Task SwitchEdit(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
var members = await ctx.ParseMemberList(ctx.System.Id);
|
||||
await DoEditCommand(ctx, members);
|
||||
}
|
||||
|
||||
public async Task SwitchEditOut(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
await DoEditCommand(ctx, new PKMember[] { });
|
||||
|
||||
}
|
||||
public async Task DoEditCommand(Context ctx, ICollection<PKMember> members)
|
||||
{
|
||||
// Make sure there are no dupes in the list
|
||||
// We do this by checking if removing duplicate member IDs results in a list of different length
|
||||
if (members.Select(m => m.Id).Distinct().Count() != members.Count) throw Errors.DuplicateSwitchMembers;
|
||||
|
||||
// Find the switch to edit
|
||||
await using var conn = await _db.Obtain();
|
||||
var lastSwitch = await _repo.GetLatestSwitch(ctx.System.Id);
|
||||
// Make sure there's at least one switch
|
||||
if (lastSwitch == null) throw Errors.NoRegisteredSwitches;
|
||||
var lastSwitchMembers = _repo.GetSwitchMembers(conn, lastSwitch.Id);
|
||||
// Make sure switch isn't being edited to have the members it already does
|
||||
if (await lastSwitchMembers.Select(m => m.Id).SequenceEqualAsync(members.Select(m => m.Id).ToAsyncEnumerable()))
|
||||
// Make sure the requested switch isn't identical to the last one
|
||||
if (await lastSwitchMembers.Select(m => m.Id)
|
||||
.SequenceEqualAsync(members.Select(m => m.Id).ToAsyncEnumerable()))
|
||||
throw Errors.SameSwitch(members, ctx.LookupContextFor(ctx.System));
|
||||
|
||||
// Send a prompt asking the user to confirm the switch
|
||||
var lastSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - lastSwitch.Timestamp).FormatDuration();
|
||||
var lastSwitchMemberStr = string.Join(", ", await lastSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync());
|
||||
var newSwitchMemberStr = string.Join(", ", members.Select(m => m.NameFor(ctx)));
|
||||
|
||||
string msg;
|
||||
if (members.Count == 0)
|
||||
msg = $"{Emojis.Warn} This will turn the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago) into a switch-out. Is this okay?";
|
||||
else
|
||||
msg = $"{Emojis.Warn} This will change the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago) to {newSwitchMemberStr}. Is this okay?";
|
||||
if (!await ctx.PromptYesNo(msg, "Edit")) throw Errors.SwitchEditCancelled;
|
||||
|
||||
// Actually edit the switch
|
||||
await _repo.EditSwitch(conn, lastSwitch.Id, members.Select(m => m.Id).ToList());
|
||||
|
||||
// Tell the user the edit suceeded
|
||||
if (members.Count == 0)
|
||||
await ctx.Reply($"{Emojis.Success} Switch edited. The latest switch is now a switch-out.");
|
||||
else
|
||||
await ctx.Reply($"{Emojis.Success} Switch edited. Current fronter is now {newSwitchMemberStr}.");
|
||||
}
|
||||
|
||||
public async Task SwitchDelete(Context ctx)
|
||||
await _repo.AddSwitch(conn, ctx.System.Id, members.Select(m => m.Id).ToList());
|
||||
|
||||
if (members.Count == 0)
|
||||
await ctx.Reply($"{Emojis.Success} Switch-out registered.");
|
||||
else
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Success} Switch registered. Current fronter is now {string.Join(", ", members.Select(m => m.NameFor(ctx)))}.");
|
||||
}
|
||||
|
||||
public async Task SwitchMove(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
var timeToMove = ctx.RemainderOrNull() ??
|
||||
throw new PKSyntaxError("Must pass a date or time to move the switch to.");
|
||||
var tz = TzdbDateTimeZoneSource.Default.ForId(ctx.System.UiTz ?? "UTC");
|
||||
|
||||
var result = DateUtils.ParseDateTime(timeToMove, true, tz);
|
||||
if (result == null) throw Errors.InvalidDateTime(timeToMove);
|
||||
|
||||
|
||||
var time = result.Value;
|
||||
if (time.ToInstant() > SystemClock.Instance.GetCurrentInstant()) throw Errors.SwitchTimeInFuture;
|
||||
|
||||
// Fetch the last two switches for the system to do bounds checking on
|
||||
var lastTwoSwitches = await _repo.GetSwitches(ctx.System.Id).Take(2).ToListAsync();
|
||||
|
||||
// If we don't have a switch to move, don't bother
|
||||
if (lastTwoSwitches.Count == 0) throw Errors.NoRegisteredSwitches;
|
||||
|
||||
// If there's a switch *behind* the one we move, we check to make sure we're not moving the time further back than that
|
||||
if (lastTwoSwitches.Count == 2)
|
||||
if (lastTwoSwitches[1].Timestamp > time.ToInstant())
|
||||
throw Errors.SwitchMoveBeforeSecondLast(lastTwoSwitches[1].Timestamp.InZone(tz));
|
||||
|
||||
// Now we can actually do the move, yay!
|
||||
// But, we do a prompt to confirm.
|
||||
var lastSwitchMembers = _db.Execute(conn => _repo.GetSwitchMembers(conn, lastTwoSwitches[0].Id));
|
||||
var lastSwitchMemberStr =
|
||||
string.Join(", ", await lastSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync());
|
||||
var lastSwitchTime = lastTwoSwitches[0].Timestamp.ToUnixTimeSeconds(); // .FormatZoned(ctx.System)
|
||||
var lastSwitchDeltaStr =
|
||||
(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp).FormatDuration();
|
||||
var newSwitchTime = time.ToInstant().ToUnixTimeSeconds();
|
||||
var newSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - time.ToInstant()).FormatDuration();
|
||||
|
||||
// yeet
|
||||
var msg =
|
||||
$"{Emojis.Warn} This will move the latest switch ({lastSwitchMemberStr}) from <t:{lastSwitchTime}> ({lastSwitchDeltaStr} ago) to <t:{newSwitchTime}> ({newSwitchDeltaStr} ago). Is this OK?";
|
||||
if (!await ctx.PromptYesNo(msg, "Move Switch")) throw Errors.SwitchMoveCancelled;
|
||||
|
||||
// aaaand *now* we do the move
|
||||
await _repo.MoveSwitch(lastTwoSwitches[0].Id, time.ToInstant());
|
||||
await ctx.Reply($"{Emojis.Success} Switch moved to <t:{newSwitchTime}> ({newSwitchDeltaStr} ago).");
|
||||
}
|
||||
|
||||
public async Task SwitchEdit(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
var members = await ctx.ParseMemberList(ctx.System.Id);
|
||||
await DoEditCommand(ctx, members);
|
||||
}
|
||||
|
||||
public async Task SwitchEditOut(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
await DoEditCommand(ctx, new PKMember[] { });
|
||||
}
|
||||
|
||||
public async Task DoEditCommand(Context ctx, ICollection<PKMember> members)
|
||||
{
|
||||
// Make sure there are no dupes in the list
|
||||
// We do this by checking if removing duplicate member IDs results in a list of different length
|
||||
if (members.Select(m => m.Id).Distinct().Count() != members.Count) throw Errors.DuplicateSwitchMembers;
|
||||
|
||||
// Find the switch to edit
|
||||
await using var conn = await _db.Obtain();
|
||||
var lastSwitch = await _repo.GetLatestSwitch(ctx.System.Id);
|
||||
// Make sure there's at least one switch
|
||||
if (lastSwitch == null) throw Errors.NoRegisteredSwitches;
|
||||
var lastSwitchMembers = _repo.GetSwitchMembers(conn, lastSwitch.Id);
|
||||
// Make sure switch isn't being edited to have the members it already does
|
||||
if (await lastSwitchMembers.Select(m => m.Id)
|
||||
.SequenceEqualAsync(members.Select(m => m.Id).ToAsyncEnumerable()))
|
||||
throw Errors.SameSwitch(members, ctx.LookupContextFor(ctx.System));
|
||||
|
||||
// Send a prompt asking the user to confirm the switch
|
||||
var lastSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - lastSwitch.Timestamp).FormatDuration();
|
||||
var lastSwitchMemberStr =
|
||||
string.Join(", ", await lastSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync());
|
||||
var newSwitchMemberStr = string.Join(", ", members.Select(m => m.NameFor(ctx)));
|
||||
|
||||
string msg;
|
||||
if (members.Count == 0)
|
||||
msg = $"{Emojis.Warn} This will turn the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago) into a switch-out. Is this okay?";
|
||||
else
|
||||
msg = $"{Emojis.Warn} This will change the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago) to {newSwitchMemberStr}. Is this okay?";
|
||||
if (!await ctx.PromptYesNo(msg, "Edit")) throw Errors.SwitchEditCancelled;
|
||||
|
||||
// Actually edit the switch
|
||||
await _repo.EditSwitch(conn, lastSwitch.Id, members.Select(m => m.Id).ToList());
|
||||
|
||||
// Tell the user the edit suceeded
|
||||
if (members.Count == 0)
|
||||
await ctx.Reply($"{Emojis.Success} Switch edited. The latest switch is now a switch-out.");
|
||||
else
|
||||
await ctx.Reply($"{Emojis.Success} Switch edited. Current fronter is now {newSwitchMemberStr}.");
|
||||
}
|
||||
|
||||
public async Task SwitchDelete(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
if (ctx.Match("all", "clear") || ctx.MatchFlag("all", "clear"))
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
if (ctx.Match("all", "clear") || ctx.MatchFlag("all", "clear"))
|
||||
{
|
||||
// Subcommand: "delete all"
|
||||
var purgeMsg = $"{Emojis.Warn} This will delete *all registered switches* in your system. Are you sure you want to proceed?";
|
||||
if (!await ctx.PromptYesNo(purgeMsg, "Clear Switches"))
|
||||
throw Errors.GenericCancelled();
|
||||
await _repo.DeleteAllSwitches(ctx.System.Id);
|
||||
await ctx.Reply($"{Emojis.Success} Cleared system switches!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the last two switches for the system to do bounds checking on
|
||||
var lastTwoSwitches = await _repo.GetSwitches(ctx.System.Id).Take(2).ToListAsync();
|
||||
if (lastTwoSwitches.Count == 0) throw Errors.NoRegisteredSwitches;
|
||||
|
||||
var lastSwitchMembers = _db.Execute(conn => _repo.GetSwitchMembers(conn, lastTwoSwitches[0].Id));
|
||||
var lastSwitchMemberStr = string.Join(", ", await lastSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync());
|
||||
var lastSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp).FormatDuration();
|
||||
|
||||
string msg;
|
||||
if (lastTwoSwitches.Count == 1)
|
||||
{
|
||||
msg = $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). You have no other switches logged. Is this okay?";
|
||||
}
|
||||
else
|
||||
{
|
||||
var secondSwitchMembers = _db.Execute(conn => _repo.GetSwitchMembers(conn, lastTwoSwitches[1].Id));
|
||||
var secondSwitchMemberStr = string.Join(", ", await secondSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync());
|
||||
var secondSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[1].Timestamp).FormatDuration();
|
||||
msg = $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). The next latest switch is {secondSwitchMemberStr} ({secondSwitchDeltaStr} ago). Is this okay?";
|
||||
}
|
||||
|
||||
if (!await ctx.PromptYesNo(msg, "Delete Switch")) throw Errors.SwitchDeleteCancelled;
|
||||
await _repo.DeleteSwitch(lastTwoSwitches[0].Id);
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} Switch deleted.");
|
||||
// Subcommand: "delete all"
|
||||
var purgeMsg =
|
||||
$"{Emojis.Warn} This will delete *all registered switches* in your system. Are you sure you want to proceed?";
|
||||
if (!await ctx.PromptYesNo(purgeMsg, "Clear Switches"))
|
||||
throw Errors.GenericCancelled();
|
||||
await _repo.DeleteAllSwitches(ctx.System.Id);
|
||||
await ctx.Reply($"{Emojis.Success} Cleared system switches!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the last two switches for the system to do bounds checking on
|
||||
var lastTwoSwitches = await _repo.GetSwitches(ctx.System.Id).Take(2).ToListAsync();
|
||||
if (lastTwoSwitches.Count == 0) throw Errors.NoRegisteredSwitches;
|
||||
|
||||
var lastSwitchMembers = _db.Execute(conn => _repo.GetSwitchMembers(conn, lastTwoSwitches[0].Id));
|
||||
var lastSwitchMemberStr =
|
||||
string.Join(", ", await lastSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync());
|
||||
var lastSwitchDeltaStr =
|
||||
(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp).FormatDuration();
|
||||
|
||||
string msg;
|
||||
if (lastTwoSwitches.Count == 1)
|
||||
{
|
||||
msg = $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). You have no other switches logged. Is this okay?";
|
||||
}
|
||||
else
|
||||
{
|
||||
var secondSwitchMembers = _db.Execute(conn => _repo.GetSwitchMembers(conn, lastTwoSwitches[1].Id));
|
||||
var secondSwitchMemberStr =
|
||||
string.Join(", ", await secondSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync());
|
||||
var secondSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[1].Timestamp)
|
||||
.FormatDuration();
|
||||
msg = $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). The next latest switch is {secondSwitchMemberStr} ({secondSwitchDeltaStr} ago). Is this okay?";
|
||||
}
|
||||
|
||||
if (!await ctx.PromptYesNo(msg, "Delete Switch")) throw Errors.SwitchDeleteCancelled;
|
||||
await _repo.DeleteSwitch(lastTwoSwitches[0].Id);
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} Switch deleted.");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +1,40 @@
|
|||
using System.Threading.Tasks;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class System
|
||||
{
|
||||
public class System
|
||||
private readonly IDatabase _db;
|
||||
private readonly EmbedService _embeds;
|
||||
private readonly ModelRepository _repo;
|
||||
|
||||
public System(EmbedService embeds, IDatabase db, ModelRepository repo)
|
||||
{
|
||||
private readonly EmbedService _embeds;
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
_embeds = embeds;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public System(EmbedService embeds, IDatabase db, ModelRepository repo)
|
||||
{
|
||||
_embeds = embeds;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
public async Task Query(Context ctx, PKSystem system)
|
||||
{
|
||||
if (system == null) throw Errors.NoSystemError;
|
||||
|
||||
public async Task Query(Context ctx, PKSystem system)
|
||||
{
|
||||
if (system == null) throw Errors.NoSystemError;
|
||||
await ctx.Reply(embed: await _embeds.CreateSystemEmbed(ctx, system, ctx.LookupContextFor(system)));
|
||||
}
|
||||
|
||||
await ctx.Reply(embed: await _embeds.CreateSystemEmbed(ctx, system, ctx.LookupContextFor(system)));
|
||||
}
|
||||
public async Task New(Context ctx)
|
||||
{
|
||||
ctx.CheckNoSystem();
|
||||
|
||||
public async Task New(Context ctx)
|
||||
{
|
||||
ctx.CheckNoSystem();
|
||||
var systemName = ctx.RemainderOrNull();
|
||||
if (systemName != null && systemName.Length > Limits.MaxSystemNameLength)
|
||||
throw Errors.StringTooLongError("System name", systemName.Length, Limits.MaxSystemNameLength);
|
||||
|
||||
var systemName = ctx.RemainderOrNull();
|
||||
if (systemName != null && systemName.Length > Limits.MaxSystemNameLength)
|
||||
throw Errors.StringTooLongError("System name", systemName.Length, Limits.MaxSystemNameLength);
|
||||
var system = await _repo.CreateSystem(systemName);
|
||||
await _repo.AddAccount(system.Id, ctx.Author.Id);
|
||||
|
||||
var system = await _repo.CreateSystem(systemName);
|
||||
await _repo.AddAccount(system.Id, ctx.Author.Id);
|
||||
|
||||
// TODO: better message, perhaps embed like in groups?
|
||||
await ctx.Reply($"{Emojis.Success} Your system has been created. Type `pk;system` to view it, and type `pk;system help` for more information about commands you can use now. Now that you have that set up, check out the getting started guide on setting up members and proxies: <https://pluralkit.me/start>");
|
||||
}
|
||||
// TODO: better message, perhaps embed like in groups?
|
||||
await ctx.Reply(
|
||||
$"{Emojis.Success} Your system has been created. Type `pk;system` to view it, and type `pk;system help` for more information about commands you can use now. Now that you have that set up, check out the getting started guide on setting up members and proxies: <https://pluralkit.me/start>");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,147 +1,149 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class SystemFront
|
||||
{
|
||||
public class SystemFront
|
||||
private readonly IDatabase _db;
|
||||
private readonly EmbedService _embeds;
|
||||
private readonly ModelRepository _repo;
|
||||
|
||||
public SystemFront(EmbedService embeds, IDatabase db, ModelRepository repo)
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
private readonly EmbedService _embeds;
|
||||
_embeds = embeds;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public SystemFront(EmbedService embeds, IDatabase db, ModelRepository repo)
|
||||
{
|
||||
_embeds = embeds;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
public async Task SystemFronter(Context ctx, PKSystem system)
|
||||
{
|
||||
if (system == null) throw Errors.NoSystemError;
|
||||
ctx.CheckSystemPrivacy(system, system.FrontPrivacy);
|
||||
|
||||
struct FrontHistoryEntry
|
||||
{
|
||||
public readonly Instant? LastTime;
|
||||
public readonly PKSwitch ThisSwitch;
|
||||
var sw = await _repo.GetLatestSwitch(system.Id);
|
||||
if (sw == null) throw Errors.NoRegisteredSwitches;
|
||||
|
||||
public FrontHistoryEntry(Instant? lastTime, PKSwitch thisSwitch)
|
||||
await ctx.Reply(embed: await _embeds.CreateFronterEmbed(sw, system.Zone, ctx.LookupContextFor(system)));
|
||||
}
|
||||
|
||||
public async Task SystemFrontHistory(Context ctx, PKSystem system)
|
||||
{
|
||||
if (system == null) throw Errors.NoSystemError;
|
||||
ctx.CheckSystemPrivacy(system, system.FrontHistoryPrivacy);
|
||||
|
||||
// Gotta be careful here: if we dispose of the connection while the IAE is alive, boom
|
||||
// todo: this comment was here, but we're not getting a connection here anymore
|
||||
// hopefully nothing breaks?
|
||||
|
||||
var totalSwitches = await _repo.GetSwitchCount(system.Id);
|
||||
if (totalSwitches == 0) throw Errors.NoRegisteredSwitches;
|
||||
|
||||
var sws = _repo.GetSwitches(system.Id)
|
||||
.Scan(new FrontHistoryEntry(null, null),
|
||||
(lastEntry, newSwitch) => new FrontHistoryEntry(lastEntry.ThisSwitch?.Timestamp, newSwitch));
|
||||
|
||||
var embedTitle = system.Name != null
|
||||
? $"Front history of {system.Name} (`{system.Hid}`)"
|
||||
: $"Front history of `{system.Hid}`";
|
||||
|
||||
await ctx.Paginate(
|
||||
sws,
|
||||
totalSwitches,
|
||||
10,
|
||||
embedTitle,
|
||||
system.Color,
|
||||
async (builder, switches) =>
|
||||
{
|
||||
LastTime = lastTime;
|
||||
ThisSwitch = thisSwitch;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SystemFronter(Context ctx, PKSystem system)
|
||||
{
|
||||
if (system == null) throw Errors.NoSystemError;
|
||||
ctx.CheckSystemPrivacy(system, system.FrontPrivacy);
|
||||
|
||||
var sw = await _repo.GetLatestSwitch(system.Id);
|
||||
if (sw == null) throw Errors.NoRegisteredSwitches;
|
||||
|
||||
await ctx.Reply(embed: await _embeds.CreateFronterEmbed(sw, system.Zone, ctx.LookupContextFor(system)));
|
||||
}
|
||||
|
||||
public async Task SystemFrontHistory(Context ctx, PKSystem system)
|
||||
{
|
||||
if (system == null) throw Errors.NoSystemError;
|
||||
ctx.CheckSystemPrivacy(system, system.FrontHistoryPrivacy);
|
||||
|
||||
// Gotta be careful here: if we dispose of the connection while the IAE is alive, boom
|
||||
// todo: this comment was here, but we're not getting a connection here anymore
|
||||
// hopefully nothing breaks?
|
||||
|
||||
var totalSwitches = await _repo.GetSwitchCount(system.Id);
|
||||
if (totalSwitches == 0) throw Errors.NoRegisteredSwitches;
|
||||
|
||||
var sws = _repo.GetSwitches(system.Id)
|
||||
.Scan(new FrontHistoryEntry(null, null),
|
||||
(lastEntry, newSwitch) => new FrontHistoryEntry(lastEntry.ThisSwitch?.Timestamp, newSwitch));
|
||||
|
||||
var embedTitle = system.Name != null ? $"Front history of {system.Name} (`{system.Hid}`)" : $"Front history of `{system.Hid}`";
|
||||
|
||||
await ctx.Paginate(
|
||||
sws,
|
||||
totalSwitches,
|
||||
10,
|
||||
embedTitle,
|
||||
system.Color,
|
||||
async (builder, switches) =>
|
||||
var sb = new StringBuilder();
|
||||
foreach (var entry in switches)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var entry in switches)
|
||||
var lastSw = entry.LastTime;
|
||||
|
||||
var sw = entry.ThisSwitch;
|
||||
|
||||
// Fetch member list and format
|
||||
|
||||
var members = await _db.Execute(c => _repo.GetSwitchMembers(c, sw.Id)).ToListAsync();
|
||||
var membersStr = members.Any()
|
||||
? string.Join(", ", members.Select(m => m.NameFor(ctx)))
|
||||
: "no fronter";
|
||||
|
||||
var switchSince = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp;
|
||||
|
||||
// If this isn't the latest switch, we also show duration
|
||||
string stringToAdd;
|
||||
if (lastSw != null)
|
||||
{
|
||||
var lastSw = entry.LastTime;
|
||||
|
||||
var sw = entry.ThisSwitch;
|
||||
|
||||
// Fetch member list and format
|
||||
|
||||
var members = await _db.Execute(c => _repo.GetSwitchMembers(c, sw.Id)).ToListAsync();
|
||||
var membersStr = members.Any() ? string.Join(", ", members.Select(m => m.NameFor(ctx))) : "no fronter";
|
||||
|
||||
var switchSince = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp;
|
||||
|
||||
// If this isn't the latest switch, we also show duration
|
||||
string stringToAdd;
|
||||
if (lastSw != null)
|
||||
{
|
||||
// Calculate the time between the last switch (that we iterated - ie. the next one on the timeline) and the current one
|
||||
var switchDuration = lastSw.Value - sw.Timestamp;
|
||||
stringToAdd =
|
||||
$"**{membersStr}** ({sw.Timestamp.FormatZoned(system.Zone)}, {switchSince.FormatDuration()} ago, for {switchDuration.FormatDuration()})\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
stringToAdd =
|
||||
$"**{membersStr}** ({sw.Timestamp.FormatZoned(system.Zone)}, {switchSince.FormatDuration()} ago)\n";
|
||||
}
|
||||
|
||||
if (sb.Length + stringToAdd.Length >= 4096)
|
||||
break;
|
||||
sb.Append(stringToAdd);
|
||||
// Calculate the time between the last switch (that we iterated - ie. the next one on the timeline) and the current one
|
||||
var switchDuration = lastSw.Value - sw.Timestamp;
|
||||
stringToAdd =
|
||||
$"**{membersStr}** ({sw.Timestamp.FormatZoned(system.Zone)}, {switchSince.FormatDuration()} ago, for {switchDuration.FormatDuration()})\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
stringToAdd =
|
||||
$"**{membersStr}** ({sw.Timestamp.FormatZoned(system.Zone)}, {switchSince.FormatDuration()} ago)\n";
|
||||
}
|
||||
|
||||
builder.Description(sb.ToString());
|
||||
if (sb.Length + stringToAdd.Length >= 4096)
|
||||
break;
|
||||
sb.Append(stringToAdd);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task SystemFrontPercent(Context ctx, PKSystem system)
|
||||
builder.Description(sb.ToString());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task SystemFrontPercent(Context ctx, PKSystem system)
|
||||
{
|
||||
if (system == null) throw Errors.NoSystemError;
|
||||
ctx.CheckSystemPrivacy(system, system.FrontHistoryPrivacy);
|
||||
|
||||
var totalSwitches = await _repo.GetSwitchCount(system.Id);
|
||||
if (totalSwitches == 0) throw Errors.NoRegisteredSwitches;
|
||||
|
||||
var durationStr = ctx.RemainderOrNull() ?? "30d";
|
||||
|
||||
// Picked the UNIX epoch as a random date
|
||||
// even though we don't store switch timestamps in UNIX time
|
||||
// I assume most people won't have switches logged previously to that (?)
|
||||
if (durationStr == "full")
|
||||
durationStr = "1970-01-01";
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
var rangeStart = DateUtils.ParseDateTime(durationStr, true, system.Zone);
|
||||
if (rangeStart == null) throw Errors.InvalidDateTime(durationStr);
|
||||
if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture;
|
||||
|
||||
var title = new StringBuilder("Frontpercent of ");
|
||||
if (system.Name != null)
|
||||
title.Append($"{system.Name} (`{system.Hid}`)");
|
||||
else
|
||||
title.Append($"`{system.Hid}`");
|
||||
|
||||
var ignoreNoFronters = ctx.MatchFlag("fo", "fronters-only");
|
||||
var showFlat = ctx.MatchFlag("flat");
|
||||
var frontpercent = await _db.Execute(c =>
|
||||
_repo.GetFrontBreakdown(c, system.Id, null, rangeStart.Value.ToInstant(), now));
|
||||
await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, system, null, system.Zone,
|
||||
ctx.LookupContextFor(system), title.ToString(), ignoreNoFronters, showFlat));
|
||||
}
|
||||
|
||||
private struct FrontHistoryEntry
|
||||
{
|
||||
public readonly Instant? LastTime;
|
||||
public readonly PKSwitch ThisSwitch;
|
||||
|
||||
public FrontHistoryEntry(Instant? lastTime, PKSwitch thisSwitch)
|
||||
{
|
||||
if (system == null) throw Errors.NoSystemError;
|
||||
ctx.CheckSystemPrivacy(system, system.FrontHistoryPrivacy);
|
||||
|
||||
var totalSwitches = await _repo.GetSwitchCount(system.Id);
|
||||
if (totalSwitches == 0) throw Errors.NoRegisteredSwitches;
|
||||
|
||||
string durationStr = ctx.RemainderOrNull() ?? "30d";
|
||||
|
||||
// Picked the UNIX epoch as a random date
|
||||
// even though we don't store switch timestamps in UNIX time
|
||||
// I assume most people won't have switches logged previously to that (?)
|
||||
if (durationStr == "full")
|
||||
durationStr = "1970-01-01";
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
var rangeStart = DateUtils.ParseDateTime(durationStr, true, system.Zone);
|
||||
if (rangeStart == null) throw Errors.InvalidDateTime(durationStr);
|
||||
if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture;
|
||||
|
||||
var title = new StringBuilder($"Frontpercent of ");
|
||||
if (system.Name != null)
|
||||
title.Append($"{system.Name} (`{system.Hid}`)");
|
||||
else
|
||||
title.Append($"`{system.Hid}`");
|
||||
|
||||
var ignoreNoFronters = ctx.MatchFlag("fo", "fronters-only");
|
||||
var showFlat = ctx.MatchFlag("flat");
|
||||
var frontpercent = await _db.Execute(c => _repo.GetFrontBreakdown(c, system.Id, null, rangeStart.Value.ToInstant(), now));
|
||||
await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, system, null, system.Zone, ctx.LookupContextFor(system), title.ToString(), ignoreNoFronters, showFlat));
|
||||
LastTime = lastTime;
|
||||
ThisSwitch = thisSwitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +1,56 @@
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Myriad.Extensions;
|
||||
using Myriad.Rest.Types;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class SystemLink
|
||||
{
|
||||
public class SystemLink
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
|
||||
public SystemLink(IDatabase db, ModelRepository repo)
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly ModelRepository _repo;
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public SystemLink(IDatabase db, ModelRepository repo)
|
||||
{
|
||||
_db = db;
|
||||
_repo = repo;
|
||||
}
|
||||
public async Task LinkSystem(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
public async Task LinkSystem(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
var account = await ctx.MatchUser() ??
|
||||
throw new PKSyntaxError("You must pass an account to link with (either ID or @mention).");
|
||||
var accountIds = await _repo.GetSystemAccounts(ctx.System.Id);
|
||||
if (accountIds.Contains(account.Id))
|
||||
throw Errors.AccountAlreadyLinked;
|
||||
|
||||
var account = await ctx.MatchUser() ?? throw new PKSyntaxError("You must pass an account to link with (either ID or @mention).");
|
||||
var accountIds = await _repo.GetSystemAccounts(ctx.System.Id);
|
||||
if (accountIds.Contains(account.Id))
|
||||
throw Errors.AccountAlreadyLinked;
|
||||
var existingAccount = await _repo.GetSystemByAccount(account.Id);
|
||||
if (existingAccount != null)
|
||||
throw Errors.AccountInOtherSystem(existingAccount);
|
||||
|
||||
var existingAccount = await _repo.GetSystemByAccount(account.Id);
|
||||
if (existingAccount != null)
|
||||
throw Errors.AccountInOtherSystem(existingAccount);
|
||||
var msg = $"{account.Mention()}, please confirm the link.";
|
||||
if (!await ctx.PromptYesNo(msg, "Confirm", account, false)) throw Errors.MemberLinkCancelled;
|
||||
await _repo.AddAccount(ctx.System.Id, account.Id);
|
||||
await ctx.Reply($"{Emojis.Success} Account linked to system.");
|
||||
}
|
||||
|
||||
var msg = $"{account.Mention()}, please confirm the link.";
|
||||
if (!await ctx.PromptYesNo(msg, "Confirm", user: account, matchFlag: false)) throw Errors.MemberLinkCancelled;
|
||||
await _repo.AddAccount(ctx.System.Id, account.Id);
|
||||
await ctx.Reply($"{Emojis.Success} Account linked to system.");
|
||||
}
|
||||
public async Task UnlinkAccount(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
|
||||
public async Task UnlinkAccount(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
ulong id;
|
||||
if (!ctx.MatchUserRaw(out id))
|
||||
throw new PKSyntaxError("You must pass an account to link with (either ID or @mention).");
|
||||
|
||||
ulong id;
|
||||
if (!ctx.MatchUserRaw(out id))
|
||||
throw new PKSyntaxError("You must pass an account to link with (either ID or @mention).");
|
||||
var accountIds = (await _repo.GetSystemAccounts(ctx.System.Id)).ToList();
|
||||
if (!accountIds.Contains(id)) throw Errors.AccountNotLinked;
|
||||
if (accountIds.Count == 1) throw Errors.UnlinkingLastAccount;
|
||||
|
||||
var accountIds = (await _repo.GetSystemAccounts(ctx.System.Id)).ToList();
|
||||
if (!accountIds.Contains(id)) throw Errors.AccountNotLinked;
|
||||
if (accountIds.Count == 1) throw Errors.UnlinkingLastAccount;
|
||||
var msg = $"Are you sure you want to unlink <@{id}> from your system?";
|
||||
if (!await ctx.PromptYesNo(msg, "Unlink")) throw Errors.MemberUnlinkCancelled;
|
||||
|
||||
var msg = $"Are you sure you want to unlink <@{id}> from your system?";
|
||||
if (!await ctx.PromptYesNo(msg, "Unlink")) throw Errors.MemberUnlinkCancelled;
|
||||
|
||||
await _repo.RemoveAccount(ctx.System.Id, id);
|
||||
await ctx.Reply($"{Emojis.Success} Account unlinked.");
|
||||
}
|
||||
await _repo.RemoveAccount(ctx.System.Id, id);
|
||||
await ctx.Reply($"{Emojis.Success} Account unlinked.");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +1,46 @@
|
|||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class SystemList
|
||||
{
|
||||
public class SystemList
|
||||
private readonly IDatabase _db;
|
||||
|
||||
public SystemList(IDatabase db)
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public SystemList(IDatabase db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
public async Task MemberList(Context ctx, PKSystem target)
|
||||
{
|
||||
if (target == null) throw Errors.NoSystemError;
|
||||
ctx.CheckSystemPrivacy(target, target.MemberListPrivacy);
|
||||
|
||||
public async Task MemberList(Context ctx, PKSystem target)
|
||||
{
|
||||
if (target == null) throw Errors.NoSystemError;
|
||||
ctx.CheckSystemPrivacy(target, target.MemberListPrivacy);
|
||||
var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(target));
|
||||
await ctx.RenderMemberList(
|
||||
ctx.LookupContextFor(target),
|
||||
_db,
|
||||
target.Id,
|
||||
GetEmbedTitle(target, opts),
|
||||
target.Color,
|
||||
opts
|
||||
);
|
||||
}
|
||||
|
||||
var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(target));
|
||||
await ctx.RenderMemberList(ctx.LookupContextFor(target), _db, target.Id, GetEmbedTitle(target, opts), target.Color, opts);
|
||||
}
|
||||
private string GetEmbedTitle(PKSystem target, MemberListOptions opts)
|
||||
{
|
||||
var title = new StringBuilder("Members of ");
|
||||
|
||||
private string GetEmbedTitle(PKSystem target, MemberListOptions opts)
|
||||
{
|
||||
var title = new StringBuilder("Members of ");
|
||||
if (target.Name != null)
|
||||
title.Append($"{target.Name} (`{target.Hid}`)");
|
||||
else
|
||||
title.Append($"`{target.Hid}`");
|
||||
|
||||
if (target.Name != null)
|
||||
title.Append($"{target.Name} (`{target.Hid}`)");
|
||||
else
|
||||
title.Append($"`{target.Hid}`");
|
||||
if (opts.Search != null)
|
||||
title.Append($" matching **{opts.Search}**");
|
||||
|
||||
if (opts.Search != null)
|
||||
title.Append($" matching **{opts.Search}**");
|
||||
|
||||
return title.ToString();
|
||||
}
|
||||
return title.ToString();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue