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/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index 5561fe99..466677ae 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -39,6 +39,9 @@ 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"); @@ -89,8 +92,8 @@ namespace PluralKit.Bot public static Command[] MemberCommands = { MemberInfo, MemberNew, MemberRename, MemberDisplayName, MemberServerName, MemberDesc, MemberPronouns, - MemberColor, MemberBirthday, MemberProxy, MemberKeepProxy, MemberDelete, MemberAvatar, MemberServerAvatar, MemberPrivacy, - MemberRandom + MemberColor, MemberBirthday, MemberProxy, MemberKeepProxy, MemberGroups, MemberGroupAdd, MemberGroupRemove, + MemberDelete, MemberAvatar, MemberServerAvatar, MemberPrivacy, MemberRandom }; public static Command[] GroupCommands = @@ -328,6 +331,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")) diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index b0e45b9e..e984aa80 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -318,49 +318,38 @@ namespace PluralKit.Bot { 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/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/Modules.cs b/PluralKit.Bot/Modules.cs index 5841149f..625fd40f 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -40,6 +40,7 @@ namespace PluralKit.Bot 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/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