diff --git a/PluralKit.Bot/CommandSystem/ContextArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextArgumentsExt.cs index 0a0c98b5..3e1b2572 100644 --- a/PluralKit.Bot/CommandSystem/ContextArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextArgumentsExt.cs @@ -86,8 +86,33 @@ namespace PluralKit.Bot members.Add(member); // Then add to the final output list } + if (members.Count == 0) throw new PKSyntaxError($"You must input at least one member."); return members; } + + public static async Task> ParseGroupList(this Context ctx, SystemId? restrictToSystem) + { + var groups = new List(); + + // Loop through all the given arguments + while (ctx.HasNext()) + { + // and attempt to match a group + var group = await ctx.MatchGroup(); + if (group == null) + // if we can't, big error. Every group name must be valid. + throw new PKError(ctx.CreateGroupNotFoundError(ctx.PopArgument())); + + if (restrictToSystem != null && group.System != restrictToSystem) + throw Errors.NotOwnGroupError; // TODO: name *which* group? + + groups.Add(group); // Then add to the final output list + } + + if (groups.Count == 0) throw new PKSyntaxError($"You must input at least one group."); + + return groups; + } } } diff --git a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs index 08190b60..32dd11c0 100644 --- a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs @@ -159,7 +159,7 @@ namespace PluralKit.Bot return null; var channel = await ctx.Shard.GetChannel(id); - if (channel == null || channel.Type != ChannelType.Text) return null; + if (channel == null || !(channel.Type == ChannelType.Text || channel.Type == ChannelType.News)) return null; ctx.PopArgument(); return channel; diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index 9b0483be..3126dbbf 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -41,12 +41,15 @@ namespace PluralKit.Bot public static Command MemberProxy = new Command("member proxy", "member proxy [add|remove] [example proxy]", "Changes, adds, or removes a member's proxy tags"); public static Command MemberDelete = new Command("member delete", "member delete", "Deletes a member"); public static Command MemberAvatar = new Command("member avatar", "member avatar [url|@mention]", "Changes a member's avatar"); + public static Command MemberGroups = new Command("member group", "member group", "Shows the groups a member is in"); + public static Command MemberGroupAdd = new Command("member group", "member group add [group 2] [group 3...]", "Adds a member to one or more groups"); + public static Command MemberGroupRemove = new Command("member group", "member group remove [group 2] [group 3...]", "Removes a member from one or more groups"); public static Command MemberServerAvatar = new Command("member serveravatar", "member serveravatar [url|@mention]", "Changes a member's avatar in the current server"); public static Command MemberDisplayName = new Command("member displayname", "member displayname [display name]", "Changes a member's display name"); public static Command MemberServerName = new Command("member servername", "member servername [server name]", "Changes a member's display name in the current server"); public static Command MemberAutoproxy = new Command("member autoproxy", "member autoproxy [on|off]", "Sets whether a member will be autoproxied when autoproxy is set to latch or front mode."); public static Command MemberKeepProxy = new Command("member keepproxy", "member keepproxy [on|off]", "Sets whether to include a member's proxy tags when proxying"); - public static Command MemberRandom = new Command("random", "random", "Looks up a random member from your system"); + public static Command MemberRandom = new Command("random", "random", "Shows the info card of a randomly selected member in your system."); public static Command MemberPrivacy = new Command("member privacy", "member privacy ", "Changes a members's privacy settings"); public static Command GroupInfo = new Command("group", "group ", "Looks up information about a group"); public static Command GroupNew = new Command("group new", "group new ", "Creates a new group"); @@ -60,6 +63,8 @@ namespace PluralKit.Bot public static Command GroupPrivacy = new Command("group privacy", "group privacy ", "Changes a group's privacy settings"); public static Command GroupIcon = new Command("group icon", "group icon [url|@mention]", "Changes a group's icon"); public static Command GroupDelete = new Command("group delete", "group delete", "Deletes a group"); + public static Command GroupMemberRandom = new Command("group random", "group random", "Shows the info card of a randomly selected member in a group."); + public static Command GroupRandom = new Command("random", "random group", "Shows the info card of a randomly selected group in your system."); public static Command Switch = new Command("switch", "switch [member 2] [member 3...]", "Registers a switch"); public static Command SwitchOut = new Command("switch out", "switch out", "Registers a switch with no members"); public static Command SwitchMove = new Command("switch move", "switch move ", "Moves the latest switch in time"); @@ -92,8 +97,8 @@ namespace PluralKit.Bot public static Command[] MemberCommands = { MemberInfo, MemberNew, MemberRename, MemberDisplayName, MemberServerName, MemberDesc, MemberPronouns, - MemberColor, MemberBirthday, MemberProxy, MemberAutoproxy, MemberKeepProxy, MemberDelete, MemberAvatar, MemberServerAvatar, MemberPrivacy, - MemberRandom + MemberColor, MemberBirthday, MemberProxy, MemberAutoproxy, MemberKeepProxy, MemberGroups, MemberGroupAdd, MemberGroupRemove, + MemberDelete, MemberAvatar, MemberServerAvatar, MemberPrivacy, MemberRandom }; public static Command[] GroupCommands = @@ -105,7 +110,7 @@ namespace PluralKit.Bot public static Command[] GroupCommandsTargeted = { GroupInfo, GroupAdd, GroupRemove, GroupMemberList, GroupRename, GroupDesc, GroupIcon, GroupPrivacy, - GroupDelete + GroupDelete, GroupMemberRandom }; public static Command[] SwitchCommands = {Switch, SwitchOut, SwitchMove, SwitchDelete, SwitchDeleteAll}; @@ -198,7 +203,10 @@ namespace PluralKit.Bot if (ctx.Match("permcheck")) return ctx.Execute(PermCheck, m => m.PermCheckGuild(ctx)); if (ctx.Match("random", "r")) - return ctx.Execute(MemberRandom, m => m.MemberRandom(ctx)); + if (ctx.Match("group", "g") || ctx.MatchFlag("group", "g")) + return ctx.Execute(GroupRandom, r => r.Group(ctx)); + else + return ctx.Execute(MemberRandom, m => m.Member(ctx)); // remove compiler warning return ctx.Reply( @@ -333,6 +341,13 @@ namespace PluralKit.Bot await ctx.Execute(MemberDelete, m => m.Delete(ctx, target)); else if (ctx.Match("avatar", "profile", "picture", "icon", "image", "pfp", "pic")) await ctx.Execute(MemberAvatar, m => m.Avatar(ctx, target)); + else if (ctx.Match("group", "groups")) + if (ctx.Match("add", "a")) + await ctx.Execute(MemberGroupAdd, m => m.AddRemove(ctx, target, Groups.AddRemoveOperation.Add)); + else if (ctx.Match("remove", "rem")) + await ctx.Execute(MemberGroupRemove, m => m.AddRemove(ctx, target, Groups.AddRemoveOperation.Remove)); + else + await ctx.Execute(MemberGroups, m => m.List(ctx, target)); else if (ctx.Match("serveravatar", "servericon", "serverimage", "serverpfp", "serverpic", "savatar", "spic", "guildavatar", "guildpic", "guildicon", "sicon")) await ctx.Execute(MemberServerAvatar, m => m.ServerAvatar(ctx, target)); else if (ctx.Match("displayname", "dn", "dname", "nick", "nickname", "dispname")) @@ -379,6 +394,8 @@ namespace PluralKit.Bot await ctx.Execute(GroupRemove, g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Remove)); else if (ctx.Match("members", "list", "ms", "l")) await ctx.Execute(GroupMemberList, g => g.ListGroupMembers(ctx, target)); + else if (ctx.Match("random")) + await ctx.Execute(GroupMemberRandom, r => r.GroupMember(ctx, target)); else if (ctx.Match("privacy")) await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, null)); else if (ctx.Match("public", "pub")) diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 09ef467b..f3f63fa5 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -18,11 +18,13 @@ namespace PluralKit.Bot { private readonly IDatabase _db; private readonly ModelRepository _repo; + private readonly EmbedService _embeds; - public Groups(IDatabase db, ModelRepository repo) + public Groups(IDatabase db, ModelRepository repo, EmbedService embeds) { _db = db; _repo = repo; + _embeds = embeds; } public async Task CreateGroup(Context ctx) @@ -177,8 +179,6 @@ namespace PluralKit.Bot { ctx.CheckOwnGroup(target); - if (img.Url.Length > Limits.MaxUriLength) - throw Errors.InvalidUrl(img.Url); await AvatarUtils.VerifyAvatarOrThrow(img.Url); await _db.Execute(c => _repo.UpdateGroup(c, target.Id, new GroupPatch {Icon = img.Url})); @@ -282,87 +282,46 @@ namespace PluralKit.Bot public async Task ShowGroupCard(Context ctx, PKGroup target) { await using var conn = await _db.Obtain(); - var system = await GetGroupSystem(ctx, target, conn); - var pctx = ctx.LookupContextFor(system); - var memberCount = ctx.MatchPrivateFlag(pctx) ? await _repo.GetGroupMemberCount(conn, target.Id, PrivacyLevel.Public) : await _repo.GetGroupMemberCount(conn, target.Id); - - var nameField = target.Name; - if (system.Name != null) - nameField = $"{nameField} ({system.Name})"; - - var eb = new DiscordEmbedBuilder() - .WithAuthor(nameField, iconUrl: DiscordUtils.WorkaroundForUrlBug(target.IconFor(pctx))) - .WithFooter($"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}"); - - if (target.DisplayName != null) - eb.AddField("Display Name", target.DisplayName); - - if (target.ListPrivacy.CanAccess(pctx)) - { - if (memberCount == 0 && pctx == LookupContext.ByOwner) - // Only suggest the add command if this is actually the owner lol - eb.AddField("Members (0)", $"Add one with `pk;group {target.Reference()} add `!", true); - else - eb.AddField($"Members ({memberCount})", $"(see `pk;group {target.Reference()} list`)", true); - } - - if (target.DescriptionFor(pctx) is {} desc) - eb.AddField("Description", desc); - - if (target.IconFor(pctx) is {} icon) - eb.WithThumbnail(icon); - - await ctx.Reply(embed: eb.Build()); + await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, system, target)); } public async Task AddRemoveMembers(Context ctx, PKGroup target, AddRemoveOperation op) { ctx.CheckOwnGroup(target); - var members = await ctx.ParseMemberList(ctx.System.Id); + var members = (await ctx.ParseMemberList(ctx.System.Id)) + .Select(m => m.Id) + .Distinct() + .ToList(); await using var conn = await _db.Obtain(); var existingMembersInGroup = (await conn.QueryMemberList(target.System, new DatabaseViewsExt.MemberListQueryOptions {GroupFilter = target.Id})) .Select(m => m.Id.Value) + .Distinct() .ToHashSet(); + List toAction; + if (op == AddRemoveOperation.Add) { - var membersNotInGroup = members - .Where(m => !existingMembersInGroup.Contains(m.Id.Value)) - .Select(m => m.Id) - .Distinct() + toAction = members + .Where(m => !existingMembersInGroup.Contains(m.Value)) .ToList(); - await _repo.AddMembersToGroup(conn, target.Id, membersNotInGroup); - - if (membersNotInGroup.Count == members.Count) - await ctx.Reply(members.Count == 0 ? $"{Emojis.Success} Member added to group." : $"{Emojis.Success} {"members".ToQuantity(membersNotInGroup.Count)} added to group."); - else - if (membersNotInGroup.Count == 0) - await ctx.Reply(members.Count == 1 ? $"{Emojis.Error} Member not added to group (member already in group)." : $"{Emojis.Error} No members added to group (members already in group)."); - else - await ctx.Reply($"{Emojis.Success} {"members".ToQuantity(membersNotInGroup.Count)} added to group ({"members".ToQuantity(members.Count - membersNotInGroup.Count)} already in group)."); + await _repo.AddMembersToGroup(conn, target.Id, toAction); } else if (op == AddRemoveOperation.Remove) { - var membersInGroup = members - .Where(m => existingMembersInGroup.Contains(m.Id.Value)) - .Select(m => m.Id) - .Distinct() + toAction = members + .Where(m => existingMembersInGroup.Contains(m.Value)) .ToList(); - await _repo.RemoveMembersFromGroup(conn, target.Id, membersInGroup); - - if (membersInGroup.Count == members.Count) - await ctx.Reply(members.Count == 0 ? $"{Emojis.Success} Member removed from group." : $"{Emojis.Success} {"members".ToQuantity(membersInGroup.Count)} removed from group."); - else - if (membersInGroup.Count == 0) - await ctx.Reply(members.Count == 1 ? $"{Emojis.Error} Member not removed from group (member already not in group)." : $"{Emojis.Error} No members removed from group (members already not in group)."); - else - await ctx.Reply($"{Emojis.Success} {"members".ToQuantity(membersInGroup.Count)} removed from group ({"members".ToQuantity(members.Count - membersInGroup.Count)} already not in group)."); + await _repo.RemoveMembersFromGroup(conn, target.Id, toAction); } + else return; // otherwise toAction "may be undefined" + + await ctx.Reply(MiscUtils.GroupAddRemoveResponse(members, toAction, op)); } public async Task ListGroupMembers(Context ctx, PKGroup target) diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs index 93bf1687..9998e1f5 100644 --- a/PluralKit.Bot/Commands/Member.cs +++ b/PluralKit.Bot/Commands/Member.cs @@ -61,29 +61,6 @@ namespace PluralKit.Bot 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 MemberRandom(Context ctx) - { - ctx.CheckSystem(); - - var randGen = new global::System.Random(); - //Maybe move this somewhere else in the file structure since it doesn't need to get created at every command - - // TODO: don't buffer these, find something else to do ig - - var members = await _db.Execute(c => - { - if (ctx.MatchFlag("all", "a")) - return _repo.GetSystemMembers(c, ctx.System.Id); - return _repo.GetSystemMembers(c, ctx.System.Id) - .Where(m => m.MemberVisibility == PrivacyLevel.Public); - }).ToListAsync(); - - if (members == null || !members.Any()) - throw Errors.NoMembersError; - var randInt = randGen.Next(members.Count); - await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild, ctx.LookupContextFor(ctx.System))); - } - public async Task ViewMember(Context ctx, PKMember target) { var system = await _db.Execute(c => _repo.GetSystem(c, target.System)); diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs index 6164b82c..786779b6 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -102,18 +102,11 @@ namespace PluralKit.Bot } ctx.CheckSystem().CheckOwnMember(target); - await ValidateUrl(avatarArg.Value.Url); + await AvatarUtils.VerifyAvatarOrThrow(avatarArg.Value.Url); await UpdateAvatar(location, ctx, target, avatarArg.Value.Url); await PrintResponse(location, ctx, target, avatarArg.Value, guildData); } - private static Task ValidateUrl(string url) - { - if (url.Length > Limits.MaxUriLength) - throw Errors.InvalidUrl(url); - return AvatarUtils.VerifyAvatarOrThrow(url); - } - private Task PrintResponse(AvatarLocation location, Context ctx, PKMember target, ParsedImage avatar, MemberGuildSettings? targetGuildData) { diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs index 3720688b..43d8fa82 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -24,10 +24,9 @@ namespace PluralKit.Bot _repo = repo; } - public async Task Name(Context ctx, PKMember target) { - // TODO: this method is pretty much a 1:1 copy/paste of the above creation method, find a way to clean? - if (ctx.System == null) throw Errors.NoSystemError; - if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; + public async Task Name(Context ctx, PKMember target) + { + ctx.CheckSystem().CheckOwnMember(target); var newName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a new name for the member."); @@ -58,15 +57,10 @@ namespace PluralKit.Bot } } - private void CheckEditMemberPermission(Context ctx, PKMember target) - { - if (target.System != ctx.System?.Id) throw Errors.NotOwnMemberError; - } - public async Task Description(Context ctx, PKMember target) { if (await ctx.MatchClear("this member's description")) { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var patch = new MemberPatch {Description = Partial.Null()}; await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); @@ -93,7 +87,7 @@ namespace PluralKit.Bot } else { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var description = ctx.RemainderOrNull().NormalizeLineEndSpacing(); if (description.IsLongerThan(Limits.MaxDescriptionLength)) @@ -109,7 +103,8 @@ namespace PluralKit.Bot public async Task Pronouns(Context ctx, PKMember target) { if (await ctx.MatchClear("this member's pronouns")) { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); + var patch = new MemberPatch {Pronouns = Partial.Null()}; await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); await ctx.Reply($"{Emojis.Success} Member pronouns cleared."); @@ -129,7 +124,7 @@ namespace PluralKit.Bot } else { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var pronouns = ctx.RemainderOrNull().NormalizeLineEndSpacing(); if (pronouns.IsLongerThan(Limits.MaxPronounsLength)) @@ -147,7 +142,7 @@ namespace PluralKit.Bot var color = ctx.RemainderOrNull(); if (await ctx.MatchClear()) { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var patch = new MemberPatch {Color = Partial.Null()}; await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); @@ -176,7 +171,7 @@ namespace PluralKit.Bot } else { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); if (color.StartsWith("#")) color = color.Substring(1); if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color); @@ -195,7 +190,7 @@ namespace PluralKit.Bot { if (await ctx.MatchClear("this member's birthday")) { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var patch = new MemberPatch {Birthday = Partial.Null()}; await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); @@ -216,7 +211,7 @@ namespace PluralKit.Bot } else { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var birthdayStr = ctx.RemainderOrNull(); var birthday = DateUtils.ParseDate(birthdayStr, true); @@ -281,7 +276,7 @@ namespace PluralKit.Bot if (await ctx.MatchClear("this member's display name")) { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var patch = new MemberPatch {DisplayName = Partial.Null()}; await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); @@ -298,7 +293,7 @@ namespace PluralKit.Bot } else { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var newDisplayName = ctx.RemainderOrNull(); @@ -315,7 +310,7 @@ namespace PluralKit.Bot if (await ctx.MatchClear("this member's server name")) { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var patch = new MemberGuildPatch {DisplayName = null}; await _db.Execute(conn => _repo.UpsertMemberGuild(conn, target.Id, ctx.Guild.Id, patch)); @@ -335,7 +330,7 @@ namespace PluralKit.Bot } else { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var newServerName = ctx.RemainderOrNull(); @@ -348,8 +343,7 @@ namespace PluralKit.Bot public async Task KeepProxy(Context ctx, PKMember target) { - if (ctx.System == null) throw Errors.NoSystemError; - if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; + ctx.CheckSystem().CheckOwnMember(target); bool newValue; if (ctx.Match("on", "enabled", "true", "yes")) newValue = true; @@ -402,8 +396,7 @@ namespace PluralKit.Bot public async Task Privacy(Context ctx, PKMember target, PrivacyLevel? newValueFromCommand) { - if (ctx.System == null) throw Errors.NoSystemError; - if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; + ctx.CheckSystem().CheckOwnMember(target); // Display privacy settings if (!ctx.HasNext() && newValueFromCommand == null) @@ -493,8 +486,7 @@ namespace PluralKit.Bot public async Task Delete(Context ctx, PKMember target) { - if (ctx.System == null) throw Errors.NoSystemError; - if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; + ctx.CheckSystem().CheckOwnMember(target); await ctx.Reply($"{Emojis.Warn} Are you sure you want to delete \"{target.NameFor(ctx)}\"? If so, reply to this message with the member's ID (`{target.Hid}`). __***This cannot be undone!***__"); if (!await ctx.ConfirmWithReply(target.Hid)) throw Errors.MemberDeleteCancelled; diff --git a/PluralKit.Bot/Commands/MemberGroup.cs b/PluralKit.Bot/Commands/MemberGroup.cs new file mode 100644 index 00000000..6a8f9f7b --- /dev/null +++ b/PluralKit.Bot/Commands/MemberGroup.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using DSharpPlus.Entities; + +using PluralKit.Core; + +namespace PluralKit.Bot +{ + public class MemberGroup + { + private readonly IDatabase _db; + private readonly ModelRepository _repo; + + public MemberGroup(IDatabase db, ModelRepository repo) + { + _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() + .ToList(); + + await using var conn = await _db.Obtain(); + var existingGroups = (await _repo.GetMemberGroups(conn, target.Id).ToListAsync()) + .Select(g => g.Id) + .Distinct() + .ToList(); + + List toAction; + + if (op == Groups.AddRemoveOperation.Add) + { + toAction = groups + .Where(group => !existingGroups.Contains(group)) + .ToList(); + + await _repo.AddGroupsToMember(conn, target.Id, toAction); + } + else if (op == Groups.AddRemoveOperation.Remove) + { + toAction = groups + .Where(group => existingGroups.Contains(group)) + .ToList(); + + await _repo.RemoveGroupsFromMember(conn, target.Id, toAction); + } + else return; // otherwise toAction "may be unassigned" + + await ctx.Reply(MiscUtils.GroupAddRemoveResponse(groups, toAction, op)); + } + + public async Task List(Context ctx, PKMember target) + { + await using var conn = await _db.Obtain(); + + var pctx = ctx.LookupContextFor(target.System); + + var groups = await _repo.GetMemberGroups(conn, 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 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 2] [group 3...]`"; + } + + await ctx.Reply(msg, embed: (new DiscordEmbedBuilder().WithTitle($"{target.Name}'s groups").WithDescription(description)).Build()); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/MemberProxy.cs b/PluralKit.Bot/Commands/MemberProxy.cs index fb9ef0fd..d09ea4cf 100644 --- a/PluralKit.Bot/Commands/MemberProxy.cs +++ b/PluralKit.Bot/Commands/MemberProxy.cs @@ -20,8 +20,7 @@ namespace PluralKit.Bot public async Task Proxy(Context ctx, PKMember target) { - if (ctx.System == null) throw Errors.NoSystemError; - if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; + ctx.CheckSystem().CheckOwnMember(target); ProxyTag ParseProxyTags(string exampleProxy) { diff --git a/PluralKit.Bot/Commands/Random.cs b/PluralKit.Bot/Commands/Random.cs new file mode 100644 index 00000000..6c154cbc --- /dev/null +++ b/PluralKit.Bot/Commands/Random.cs @@ -0,0 +1,79 @@ +using System.Linq; +using System.Threading.Tasks; + +using PluralKit.Core; + +namespace PluralKit.Bot +{ + public class Random + { + private readonly IDatabase _db; + private readonly ModelRepository _repo; + private readonly EmbedService _embeds; + + private readonly global::System.Random randGen = new global::System.Random(); + + public Random(EmbedService embeds, IDatabase db, ModelRepository repo) + { + _embeds = embeds; + _db = db; + _repo = repo; + } + + // todo: get postgresql to return one random member/group instead of querying all members/groups + + public async Task Member(Context ctx) + { + ctx.CheckSystem(); + + var members = await _db.Execute(c => + { + if (ctx.MatchFlag("all", "a")) + return _repo.GetSystemMembers(c, ctx.System.Id); + return _repo.GetSystemMembers(c, ctx.System.Id) + .Where(m => m.MemberVisibility == PrivacyLevel.Public); + }).ToListAsync(); + + if (members == null || !members.Any()) + throw new PKError("Your system has no members! Please create at least one member before using this command."); + + var randInt = randGen.Next(members.Count); + await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild, ctx.LookupContextFor(ctx.System))); + } + + public async Task Group(Context ctx) + { + ctx.CheckSystem(); + + var groups = await _db.Execute(c => c.QueryGroupList(ctx.System.Id)); + if (!ctx.MatchFlag("all", "a")) + groups = groups.Where(g => g.Visibility == PrivacyLevel.Public); + + if (groups == null || !groups.Any()) + throw new PKError("Your system has no groups! Please create at least one group before using this command."); + + var randInt = randGen.Next(groups.Count()); + await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, ctx.System, groups.ToArray()[randInt])); + } + + public async Task GroupMember(Context ctx, PKGroup group) + { + var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(group.System)); + opts.GroupFilter = group.Id; + + await using var conn = await _db.Obtain(); + var members = await conn.QueryMemberList(ctx.System.Id, opts.ToQueryOptions()); + + 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."); + + 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))); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index 39676ebd..a4f641af 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -140,8 +140,6 @@ namespace PluralKit.Bot async Task SetIcon(ParsedImage img) { - if (img.Url.Length > Limits.MaxUriLength) - throw Errors.InvalidUrl(img.Url); await AvatarUtils.VerifyAvatarOrThrow(img.Url); await _db.Execute(c => _repo.UpdateSystem(c, ctx.System.Id, new SystemPatch {AvatarUrl = img.Url})); diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 79600f68..4ad5a2d0 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -45,7 +45,6 @@ namespace PluralKit.Bot { public static PKError MemberNameTooLongError(int length) => new PKError($"Member name too long ({length}/{Limits.MaxMemberNameLength} characters)."); public static PKError MemberPronounsTooLongError(int length) => new PKError($"Member pronouns too long ({length}/{Limits.MaxMemberNameLength} characters)."); public static PKError MemberLimitReachedError(int limit) => new PKError($"System has reached the maximum number of members ({limit}). Please delete unused members first in order to create new ones."); - public static PKError NoMembersError => new PKError("Your system has no members! Please create at least one member before using this command."); public static PKError InvalidColorError(string color) => new PKError($"\"{color}\" is not a valid color. Color must be in 6-digit RGB hex format (eg. #ff0000)."); public static PKError BirthdayParseError(string birthday) => new PKError($"\"{birthday}\" could not be parsed as a valid date. Try a format like \"2016-12-24\" or \"May 3 1996\"."); diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 5841149f..b960a835 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -40,8 +40,10 @@ namespace PluralKit.Bot builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); + builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); + builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); diff --git a/PluralKit.Bot/Proxy/ProxyService.cs b/PluralKit.Bot/Proxy/ProxyService.cs index 6f77e42b..372a8c58 100644 --- a/PluralKit.Bot/Proxy/ProxyService.cs +++ b/PluralKit.Bot/Proxy/ProxyService.cs @@ -55,7 +55,9 @@ namespace PluralKit.Bot // Permission check after proxy match so we don't get spammed when not actually proxying if (!await CheckBotPermissionsOrError(message.Channel)) return false; - if (!CheckProxyNameBoundsOrError(match.Member.ProxyName(ctx))) return false; + + // this method throws, so no need to wrap it in an if statement + CheckProxyNameBoundsOrError(match.Member.ProxyName(ctx)); // Check if the sender account can mention everyone/here + embed links // we need to "mirror" these permissions when proxying to prevent exploits @@ -74,7 +76,7 @@ namespace PluralKit.Bot if (ctx.SystemId == null) return false; // Make sure channel is a guild text channel and this is a normal message - if (msg.Channel.Type != ChannelType.Text || msg.MessageType != MessageType.Default) return false; + if ((msg.Channel.Type != ChannelType.Text && msg.Channel.Type != ChannelType.News) || msg.MessageType != MessageType.Default) return false; // Make sure author is a normal user if (msg.Author.IsSystem == true || msg.Author.IsBot || msg.WebhookMessage) return false; @@ -96,7 +98,7 @@ namespace PluralKit.Bot // Send the webhook var content = match.ProxyContent; if (!allowEmbeds) content = content.BreakLinkEmbeds(); - var proxyMessage = await _webhookExecutor.ExecuteWebhook(trigger.Channel, match.Member.ProxyName(ctx), + var proxyMessage = await _webhookExecutor.ExecuteWebhook(trigger.Channel, FixSingleCharacterName(match.Member.ProxyName(ctx)), match.Member.ProxyAvatar(ctx), content, trigger.Attachments, allowEveryone); @@ -185,13 +187,15 @@ namespace PluralKit.Bot return true; } - private bool CheckProxyNameBoundsOrError(string proxyName) + private string FixSingleCharacterName(string proxyName) { - if (proxyName.Length < 2) throw Errors.ProxyNameTooShort(proxyName); - if (proxyName.Length > Limits.MaxProxyNameLength) throw Errors.ProxyNameTooLong(proxyName); + if (proxyName.Length == 1) return proxyName += "\u17b5"; + else return proxyName; + } - // TODO: this never returns false as it throws instead, should this happen? - return true; + private void CheckProxyNameBoundsOrError(string proxyName) + { + if (proxyName.Length > Limits.MaxProxyNameLength) throw Errors.ProxyNameTooLong(proxyName); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index b34008d1..f9ca05bb 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -157,6 +157,42 @@ namespace PluralKit.Bot { return eb.Build(); } + public async Task CreateGroupEmbed(Context ctx, PKSystem system, PKGroup target) + { + await using var conn = await _db.Obtain(); + + var pctx = ctx.LookupContextFor(system); + var memberCount = ctx.MatchPrivateFlag(pctx) ? await _repo.GetGroupMemberCount(conn, target.Id, PrivacyLevel.Public) : await _repo.GetGroupMemberCount(conn, target.Id); + + var nameField = target.Name; + if (system.Name != null) + nameField = $"{nameField} ({system.Name})"; + + var eb = new DiscordEmbedBuilder() + .WithAuthor(nameField, iconUrl: DiscordUtils.WorkaroundForUrlBug(target.IconFor(pctx))) + .WithFooter($"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}"); + + if (target.DisplayName != null) + eb.AddField("Display Name", target.DisplayName); + + if (target.ListPrivacy.CanAccess(pctx)) + { + if (memberCount == 0 && pctx == LookupContext.ByOwner) + // Only suggest the add command if this is actually the owner lol + eb.AddField("Members (0)", $"Add one with `pk;group {target.Reference()} add `!", true); + else + eb.AddField($"Members ({memberCount})", $"(see `pk;group {target.Reference()} list`)", true); + } + + if (target.DescriptionFor(pctx) is {} desc) + eb.AddField("Description", desc); + + if (target.IconFor(pctx) is {} icon) + eb.WithThumbnail(icon); + + return eb.Build(); + } + public async Task CreateFronterEmbed(PKSwitch sw, DateTimeZone zone, LookupContext ctx) { var members = await _db.Execute(c => _repo.GetSwitchMembers(c, sw.Id).ToListAsync().AsTask()); diff --git a/PluralKit.Bot/Services/LoggerCleanService.cs b/PluralKit.Bot/Services/LoggerCleanService.cs index 3da2d743..41ff4fa6 100644 --- a/PluralKit.Bot/Services/LoggerCleanService.cs +++ b/PluralKit.Bot/Services/LoggerCleanService.cs @@ -29,6 +29,7 @@ namespace PluralKit.Bot private static readonly Regex _vanessaRegex = new Regex("Message sent by <@!?(\\d{17,19})> deleted in"); private static readonly Regex _salRegex = new Regex("\\(ID: (\\d{17,19})\\)"); private static readonly Regex _GearBotRegex = new Regex("\\(``(\\d{17,19})``\\) in <#\\d{17,19}> has been removed."); + private static readonly Regex _GiselleRegex = new Regex("\\*\\*Message ID\\*\\*: `(\\d{17,19})`"); private static readonly Dictionary _bots = new[] { @@ -48,7 +49,8 @@ namespace PluralKit.Bot new LoggerBot("UnbelievaBoat", 292953664492929025, ExtractUnbelievaBoat, webhookName: "UnbelievaBoat"), new LoggerBot("Vanessa", 310261055060443136, fuzzyExtractFunc: ExtractVanessa), new LoggerBot("SafetyAtLast", 401549924199694338, fuzzyExtractFunc: ExtractSAL), - new LoggerBot("GearBot", 349977940198555660, fuzzyExtractFunc: ExtractGearBot) + new LoggerBot("GearBot", 349977940198555660, fuzzyExtractFunc: ExtractGearBot), + new LoggerBot("GiselleBot", 356831787445387285, ExtractGiselleBot) }.ToDictionary(b => b.Id); private static readonly Dictionary _botsByWebhookName = _bots.Values @@ -292,6 +294,13 @@ namespace PluralKit.Bot : (FuzzyExtractResult?) null; } + private static ulong? ExtractGiselleBot(DiscordMessage msg) + { + var embed = msg.Embeds.FirstOrDefault(); + if (embed?.Title == null || embed.Title != "🗑 Message Deleted") return null; + var match = _GiselleRegex.Match(embed?.Description); + return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; + } public class LoggerBot { diff --git a/PluralKit.Bot/Utils/AvatarUtils.cs b/PluralKit.Bot/Utils/AvatarUtils.cs index 837fc2fc..df4881ab 100644 --- a/PluralKit.Bot/Utils/AvatarUtils.cs +++ b/PluralKit.Bot/Utils/AvatarUtils.cs @@ -11,6 +11,9 @@ namespace PluralKit.Bot { public static class AvatarUtils { public static async Task VerifyAvatarOrThrow(string url) { + if (url.Length > Limits.MaxUriLength) + throw Errors.UrlTooLong(url); + // List of MIME types we consider acceptable var acceptableMimeTypes = new[] { diff --git a/PluralKit.Bot/Utils/MiscUtils.cs b/PluralKit.Bot/Utils/MiscUtils.cs index 4d0c2e22..7e8eb3b9 100644 --- a/PluralKit.Bot/Utils/MiscUtils.cs +++ b/PluralKit.Bot/Utils/MiscUtils.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Collections.Generic; using System.Net.Sockets; using System.Threading.Tasks; @@ -17,6 +18,37 @@ namespace PluralKit.Bot public static string ProxyTagsString(this PKMember member, string separator = ", ") => string.Join(separator, member.ProxyTags.Select(t => t.ProxyString.AsCode())); + + private static String entityTerm(int count, bool isTarget) + { + var ret = ""; + ret += isTarget ? "Member" : "Group"; + if (( + (typeof(T) == typeof(GroupId) && !isTarget) || + (typeof(T) == typeof(MemberId) && isTarget) + ) && count > 1) + ret += "s"; + return ret; + } + + public static String GroupAddRemoveResponse(List entityList, List actionedOn, Groups.AddRemoveOperation op) + { + var opStr = op == Groups.AddRemoveOperation.Add ? "added to" : "removed from"; + var inStr = op == Groups.AddRemoveOperation.Add ? "in" : "not in"; + var notActionedOn = entityList.Count - actionedOn.Count; + + var groupNotActionedPosStr = typeof(T) == typeof(GroupId) ? notActionedOn.ToString() + " " : ""; + var memberNotActionedPosStr = typeof(T) == typeof(MemberId) ? notActionedOn.ToString() + " " : ""; + + if (actionedOn.Count == 0) + return $"{Emojis.Error} {entityTerm(notActionedOn, true)} not {opStr} {entityTerm(entityList.Count, false).ToLower()} ({entityTerm(notActionedOn, true).ToLower()} already {inStr} {entityTerm(entityList.Count, false).ToLower()})."; + else + if (notActionedOn == 0) + return $"{Emojis.Success} {entityTerm(actionedOn.Count, true)} {opStr} {entityTerm(actionedOn.Count, false).ToLower()}."; + else + return $"{Emojis.Success} {entityTerm(actionedOn.Count, true)} {opStr} {actionedOn.Count} {entityTerm(actionedOn.Count, false).ToLower()} ({memberNotActionedPosStr}{entityTerm(actionedOn.Count, true).ToLower()} already {inStr} {groupNotActionedPosStr}{entityTerm(notActionedOn, false).ToLower()})."; + } + public static bool IsOurProblem(this Exception e) { // This function filters out sporadic errors out of our control from being reported to Sentry diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Group.cs b/PluralKit.Core/Database/Repository/ModelRepository.Group.cs index fdcc1e5a..7a88dba7 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.Group.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.Group.cs @@ -30,11 +30,6 @@ namespace PluralKit.Core return conn.QuerySingleOrDefaultAsync(query.ToString(), new {Id = id, PrivacyFilter = privacyFilter}); } - public IAsyncEnumerable GetMemberGroups(IPKConnection conn, MemberId id) => - conn.QueryStreamAsync( - "select groups.* from group_members inner join groups on group_members.group_id = groups.id where group_members.member_id = @Id", - new {Id = id}); - public async Task CreateGroup(IPKConnection conn, SystemId system, string name) { var group = await conn.QueryFirstAsync( diff --git a/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs b/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs new file mode 100644 index 00000000..7ac0d65c --- /dev/null +++ b/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Dapper; + +namespace PluralKit.Core +{ + public partial class ModelRepository + { + public IAsyncEnumerable GetMemberGroups(IPKConnection conn, MemberId id) => + conn.QueryStreamAsync( + "select groups.* from group_members inner join groups on group_members.group_id = groups.id where group_members.member_id = @Id", + new {Id = id}); + + + public async Task AddGroupsToMember(IPKConnection conn, MemberId member, IReadOnlyCollection groups) + { + await using var w = + conn.BeginBinaryImport("copy group_members (group_id, member_id) from stdin (format binary)"); + foreach (var group in groups) + { + await w.StartRowAsync(); + await w.WriteAsync(group.Value); + await w.WriteAsync(member.Value); + } + + await w.CompleteAsync(); + _logger.Information("Added member {MemberId} to groups {GroupIds}", member, groups); + } + + public Task RemoveGroupsFromMember(IPKConnection conn, MemberId member, IReadOnlyCollection groups) + { + _logger.Information("Removed groups from {MemberId}: {GroupIds}", member, groups); + return conn.ExecuteAsync("delete from group_members where member_id = @Member and group_id = any(@Groups)", + new {Member = @member, Groups = groups.ToArray() }); + } + + } +} \ No newline at end of file diff --git a/docs/content/.vuepress/config.js b/docs/content/.vuepress/config.js index abd2de20..99ded206 100644 --- a/docs/content/.vuepress/config.js +++ b/docs/content/.vuepress/config.js @@ -1,5 +1,6 @@ module.exports = { title: 'PluralKit', + theme: 'default-prefers-color-scheme', base: "/", head: [ diff --git a/docs/content/command-list.md b/docs/content/command-list.md index 274b06d1..93ba23b9 100644 --- a/docs/content/command-list.md +++ b/docs/content/command-list.md @@ -55,7 +55,6 @@ Words in **\** or **[square brackets]** mean fill-in-the-blank. - `pk;member color [color]` - Changes the color of a member. - `pk;member birthdate [birthdate]` - Changes the birthday of a member. - `pk;member delete` - Deletes a member. -- `pk;random` - Shows the member card of a randomly selected member in your system. ## Group commands *Replace `` with a group's name, 5-character ID or display name. For most commands, adding `-clear` will clear/delete the field.* @@ -63,6 +62,7 @@ Words in **\** or **[square brackets]** mean fill-in-the-blank. - `pk;group new ` - Creates a new group. - `pk;group list` - Lists all groups in your system. - `pk;group list` - Lists all members in a group. +- `pk;group random` - Shows the info card of a randomly selected member in a group. - `pk;group rename ` - Renames a group. - `pk;group displayname [display name]` - Shows or changes a group's display name. - `pk;group description [description]` - Shows or changes a group's description. @@ -95,6 +95,7 @@ Words in **\** or **[square brackets]** mean fill-in-the-blank. - `pk;blacklist remove <#channel> [#channel...]` - Removes the given channel(s) from the proxy blacklist. ## Utility +- `pk;random [-group]` - Shows the info card of a randomly selected member [or group] in your system. - `pk;message ` - Looks up information about a proxied message by its message ID or link. - `pk;invite` - Sends the bot invite link for PluralKit. - `pk;import` - Imports a data file from PluralKit or Tupperbox. diff --git a/docs/package.json b/docs/package.json index 7ff4ea14..40032202 100644 --- a/docs/package.json +++ b/docs/package.json @@ -14,5 +14,8 @@ "vuepress": "^1.3.1", "vuepress-plugin-clean-urls": "^1.1.1", "vuepress-plugin-dehydrate": "^1.1.4" + }, + "dependencies": { + "vuepress-theme-default-prefers-color-scheme": "^1.1.1" } } diff --git a/docs/yarn.lock b/docs/yarn.lock index c4248be7..f3159ea6 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -2480,6 +2480,13 @@ css-parse@~2.0.0: dependencies: css "^2.0.0" +css-prefers-color-scheme@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/css-prefers-color-scheme/-/css-prefers-color-scheme-3.1.1.tgz#6f830a2714199d4f0d0d0bb8a27916ed65cff1f4" + integrity sha512-MTu6+tMs9S3EUqzmqLXEcgNRbNkkD/TGFvowpeoWJn5Vfq7FMgsmRQs9X5NXAURiOBmOxm/lLjsDNXDE6k9bhg== + dependencies: + postcss "^7.0.5" + css-select-base-adapter@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz#3b2ff4972cc362ab88561507a95408a1432135d7" @@ -7478,6 +7485,13 @@ vuepress-plugin-smooth-scroll@^0.0.3: dependencies: smoothscroll-polyfill "^0.4.3" +vuepress-theme-default-prefers-color-scheme@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/vuepress-theme-default-prefers-color-scheme/-/vuepress-theme-default-prefers-color-scheme-1.1.1.tgz#11389abba0f1c15f2dbea724e80b60937bda70f8" + integrity sha512-aLWYuFRk5EFcE4bAGzokAoOD92T/daodnZnuZnzF46jOl/ZtYHFV83uwXlbBUerdQE/IAxgtfuYRELXY5sUIKA== + dependencies: + css-prefers-color-scheme "^3.1.1" + vuepress@^1.3.1: version "1.5.2" resolved "https://registry.yarnpkg.com/vuepress/-/vuepress-1.5.2.tgz#b79e84bfaade55ba3ddb59c3a937220913f0599b"