diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs index b259b9ec..ccdd03be 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -25,6 +25,7 @@ namespace PluralKit.Bot private readonly MessageContext _messageContext; private readonly IDataStore _data; + private readonly IDatabase _db; private readonly PKSystem _senderSystem; private readonly IMetrics _metrics; @@ -40,6 +41,7 @@ namespace PluralKit.Bot _data = provider.Resolve(); _senderSystem = senderSystem; _messageContext = messageContext; + _db = provider.Resolve(); _metrics = provider.Resolve(); _provider = provider; _parameters = new Parameters(message.Content.Substring(commandParseOffset)); @@ -61,6 +63,7 @@ namespace PluralKit.Bot // TODO: this is just here so the extension methods can access it; should it be public/private/? internal IDataStore DataStore => _data; + internal IDatabase Database => _db; public Task Reply(string text = null, DiscordEmbed embed = null, IEnumerable mentions = null) { diff --git a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs index 46fb6499..5ae896bb 100644 --- a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs @@ -25,6 +25,13 @@ namespace PluralKit.Bot return ctx; } + public static Context CheckOwnGroup(this Context ctx, PKGroup group) + { + if (group.System != ctx.System?.Id) + throw Errors.NotOwnGroupError; + return ctx; + } + public static Context CheckSystem(this Context ctx) { if (ctx.System == null) diff --git a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs index 72a8bb1b..4926efe7 100644 --- a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs @@ -97,6 +97,26 @@ namespace PluralKit.Bot // Finally, we return the member value. return member; } + + public static async Task PeekGroup(this Context ctx) + { + var input = ctx.PeekArgument(); + + await using var conn = await ctx.Database.Obtain(); + if (ctx.System != null && await conn.QueryGroupByName(ctx.System.Id, input) is {} byName) + return byName; + if (await conn.QueryGroupByHid(input) is {} byHid) + return byHid; + + return null; + } + + public static async Task MatchGroup(this Context ctx) + { + var group = await ctx.PeekGroup(); + if (group != null) ctx.PopArgument(); + return group; + } public static string CreateMemberNotFoundError(this Context ctx, string input) { @@ -113,6 +133,21 @@ namespace PluralKit.Bot return $"Member not found. Note that a member ID is 5 characters long."; } + public static string CreateGroupNotFoundError(this Context ctx, string input) + { + // TODO: does this belong here? + if (input.Length == 5) + { + if (ctx.System != null) + return $"Group with ID or name \"{input}\" not found."; + return $"Group with ID \"{input}\" not found."; // Accounts without systems can't query by name + } + + if (ctx.System != null) + return $"Group with name \"{input}\" not found. Note that a group ID is 5 characters long."; + return $"Group not found. Note that a group ID is 5 characters long."; + } + public static async Task MatchChannel(this Context ctx) { if (!MentionUtils.TryParseChannel(ctx.PeekArgument(), out var id)) diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index fba7cc0a..f1c9b57a 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -45,6 +45,18 @@ namespace PluralKit.Bot 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 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"); + public static Command GroupList = new Command("group list", "group list", "Lists all groups in this system"); + public static Command GroupMemberList = new Command("group members", "group list", "Lists all members in a group"); + public static Command GroupRename = new Command("group rename", "group name ", "Renames a group"); + public static Command GroupDisplayName = new Command("group displayname", "group displayname [display name]", "Changes a group's display name"); + public static Command GroupDesc = new Command("group description", "group description [description]", "Changes a group's description"); + public static Command GroupAdd = new Command("group add", "group add [member 2] [member 3...]", "Adds one or more members to a group"); + public static Command GroupRemove = new Command("group remove", "group remove [member 2] [member 3...]", "Removes one or more members from a group"); + public static Command GroupPrivacy = new Command("group privacy", "group privacy ", "Changes a group's privacy settings"); + public static Command GroupDelete = new Command("group delete", "group delete", "Deletes a group"); + public static Command GroupIcon = new Command("group icon", "group icon [url|@mention]", "Changes a group's icon"); 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"); @@ -79,6 +91,18 @@ namespace PluralKit.Bot MemberRandom }; + public static Command[] GroupCommands = + { + GroupInfo, GroupList, GroupNew, GroupAdd, GroupRemove, GroupMemberList, GroupRename, GroupDesc, + GroupIcon, GroupPrivacy, GroupDelete + }; + + public static Command[] GroupCommandsTargeted = + { + GroupInfo, GroupAdd, GroupRemove, GroupMemberList, GroupRename, GroupDesc, GroupIcon, GroupPrivacy, + GroupDelete + }; + public static Command[] SwitchCommands = {Switch, SwitchOut, SwitchMove, SwitchDelete}; public static Command[] LogCommands = {LogChannel, LogEnable, LogDisable}; @@ -97,6 +121,8 @@ namespace PluralKit.Bot return HandleSystemCommand(ctx); if (ctx.Match("member", "m")) return HandleMemberCommand(ctx); + if (ctx.Match("group", "g")) + return HandleGroupCommand(ctx); if (ctx.Match("switch", "sw")) return HandleSwitchCommand(ctx); if (ctx.Match("ap", "autoproxy", "auto")) @@ -214,6 +240,8 @@ namespace PluralKit.Bot await ctx.Execute(SystemPing, m => m.SystemPing(ctx)); else if (ctx.Match("commands", "help")) await PrintCommandList(ctx, "systems", SystemCommands); + else if (ctx.Match("groups", "gs")) + await ctx.Execute(GroupList, g => g.ListSystemGroups(ctx, null)); else if (!ctx.HasNext()) // Bare command await ctx.Execute(SystemInfo, m => m.Query(ctx, ctx.System)); else @@ -249,6 +277,8 @@ namespace PluralKit.Bot await ctx.Execute(SystemFrontPercent, m => m.SystemFrontPercent(ctx, target)); else if (ctx.Match("info", "view", "show")) await ctx.Execute(SystemInfo, m => m.Query(ctx, target)); + else if (ctx.Match("groups", "gs")) + await ctx.Execute(GroupList, g => g.ListSystemGroups(ctx, target)); else if (!ctx.HasNext()) await ctx.Execute(SystemInfo, m => m.Query(ctx, target)); else @@ -312,6 +342,51 @@ namespace PluralKit.Bot await PrintCommandNotFoundError(ctx, MemberInfo, MemberRename, MemberDisplayName, MemberServerName ,MemberDesc, MemberPronouns, MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar, SystemList); } + private async Task HandleGroupCommand(Context ctx) + { + // Commands with no group argument + if (ctx.Match("n", "new")) + await ctx.Execute(GroupNew, g => g.CreateGroup(ctx)); + else if (ctx.Match("list", "l")) + await ctx.Execute(GroupList, g => g.ListSystemGroups(ctx, null)); + else if (ctx.Match("commands", "help")) + await PrintCommandList(ctx, "groups", GroupCommands); + else if (await ctx.MatchGroup() is {} target) + { + // Commands with group argument + if (ctx.Match("rename", "name", "changename", "setname")) + await ctx.Execute(GroupRename, g => g.RenameGroup(ctx, target)); + else if (ctx.Match("dn", "displayname", "nickname")) + await ctx.Execute(GroupDisplayName, g => g.GroupDisplayName(ctx, target)); + else if (ctx.Match("description", "info", "bio", "text", "desc")) + await ctx.Execute(GroupDesc, g => g.GroupDescription(ctx, target)); + else if (ctx.Match("add", "a")) + await ctx.Execute(GroupAdd,g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Add)); + else if (ctx.Match("remove", "rem", "r")) + 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("privacy")) + await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, null)); + else if (ctx.Match("public", "pub")) + await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Public)); + else if (ctx.Match("private", "priv")) + await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Private)); + else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet")) + await ctx.Execute(GroupDelete, g => g.DeleteGroup(ctx, target)); + else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp")) + await ctx.Execute(GroupIcon, g => g.GroupIcon(ctx, target)); + else if (!ctx.HasNext()) + await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, target)); + else + await PrintCommandNotFoundError(ctx, GroupCommandsTargeted); + } + else if (!ctx.HasNext()) + await PrintCommandNotFoundError(ctx, GroupCommands); + else + await ctx.Reply($"{Emojis.Error} {ctx.CreateGroupNotFoundError(ctx.PopArgument())}"); + } + private async Task HandleSwitchCommand(Context ctx) { if (ctx.Match("out")) diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs new file mode 100644 index 00000000..e4471cba --- /dev/null +++ b/PluralKit.Bot/Commands/Groups.cs @@ -0,0 +1,498 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +using Dapper; + +using DSharpPlus.Entities; + +using Humanizer; + +using PluralKit.Core; + +namespace PluralKit.Bot +{ + public class Groups + { + private readonly IDatabase _db; + + public Groups(IDatabase db) + { + _db = db; + } + + public async Task CreateGroup(Context ctx) + { + ctx.CheckSystem(); + + // Check group name length + var groupName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a group name."); + if (groupName.Length > Limits.MaxGroupNameLength) + throw new PKError($"Group name too long ({groupName.Length}/{Limits.MaxGroupNameLength} characters)."); + + await using var conn = await _db.Obtain(); + + // Check group cap + var existingGroupCount = await conn.QuerySingleAsync("select count(*) from groups where system = @System", new { System = ctx.System.Id }); + if (existingGroupCount >= Limits.MaxGroupCount) + throw new PKError($"System has reached the maximum number of groups ({Limits.MaxGroupCount}). Please delete unused groups first in order to create new ones."); + + // Warn if there's already a group by this name + var existingGroup = await conn.QueryGroupByName(ctx.System.Id, groupName); + if (existingGroup != null) { + var msg = $"{Emojis.Warn} You already have a group in your system with the name \"{existingGroup.Name}\" (with ID `{existingGroup.Hid}`). Do you want to create another group with the same name?"; + if (!await ctx.PromptYesNo(msg)) + throw new PKError("Group creation cancelled."); + } + + var newGroup = await conn.CreateGroup(ctx.System.Id, groupName); + + var eb = new DiscordEmbedBuilder() + .WithDescription($"Your new group, **{groupName}**, has been created, with the group ID **`{newGroup.Hid}`**.\nBelow are a couple of useful commands:") + .AddField("View the group card", $"> pk;group **{GroupReference(newGroup)}**") + .AddField("Add members to the group", $"> pk;group **{GroupReference(newGroup)}** add **MemberName**\n> pk;group **{GroupReference(newGroup)}** add **Member1** **Member2** **Member3** (and so on...)") + .AddField("Set the description", $"> pk;group **{GroupReference(newGroup)}** description **This is my new group, and here is the description!**") + .AddField("Set the group icon", $"> pk;group **{GroupReference(newGroup)}** icon\n*(with an image attached)*"); + await ctx.Reply($"{Emojis.Success} Group created!", eb.Build()); + } + + public async Task RenameGroup(Context ctx, PKGroup target) + { + ctx.CheckOwnGroup(target); + + // Check group name length + var newName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a new group name."); + if (newName.Length > Limits.MaxGroupNameLength) + throw new PKError($"New group name too long ({newName.Length}/{Limits.MaxMemberNameLength} characters)."); + + await using var conn = await _db.Obtain(); + + // Warn if there's already a group by this name + var existingGroup = await conn.QueryGroupByName(ctx.System.Id, newName); + if (existingGroup != null && existingGroup.Id != target.Id) { + var msg = $"{Emojis.Warn} You already have a group in your system with the name \"{existingGroup.Name}\" (with ID `{existingGroup.Hid}`). Do you want to rename this member to that name too?"; + if (!await ctx.PromptYesNo(msg)) + throw new PKError("Group creation cancelled."); + } + + await conn.UpdateGroup(target.Id, new GroupPatch {Name = newName}); + + await ctx.Reply($"{Emojis.Success} Group name changed from **{target.Name}** to **{newName}**."); + } + + public async Task GroupDisplayName(Context ctx, PKGroup target) + { + if (ctx.MatchClear()) + { + ctx.CheckOwnGroup(target); + + var patch = new GroupPatch {DisplayName = Partial.Null()}; + await _db.Execute(conn => conn.UpdateGroup(target.Id, patch)); + + await ctx.Reply($"{Emojis.Success} Group display name cleared."); + } + else if (!ctx.HasNext()) + { + // No perms check, display name isn't covered by member privacy + var eb = new DiscordEmbedBuilder() + .AddField("Name", target.Name) + .AddField("Display Name", target.DisplayName ?? "*(none)*"); + + if (ctx.System?.Id == target.System) + eb.WithDescription($"To change display name, type `pk;group {GroupReference(target)} displayname `.\nTo clear it, type `pk;group {GroupReference(target)} displayname -clear`."); + + await ctx.Reply(embed: eb.Build()); + } + else + { + ctx.CheckOwnGroup(target); + + var newDisplayName = ctx.RemainderOrNull(); + + var patch = new GroupPatch {DisplayName = Partial.Present(newDisplayName)}; + await _db.Execute(conn => conn.UpdateGroup(target.Id, patch)); + + await ctx.Reply($"{Emojis.Success} Group display name changed."); + } + } + + public async Task GroupDescription(Context ctx, PKGroup target) + { + if (ctx.MatchClear()) + { + ctx.CheckOwnGroup(target); + + var patch = new GroupPatch {Description = Partial.Null()}; + await _db.Execute(conn => conn.UpdateGroup(target.Id, patch)); + await ctx.Reply($"{Emojis.Success} Group description cleared."); + } + else if (!ctx.HasNext()) + { + if (target.Description == null) + if (ctx.System?.Id == target.System) + await ctx.Reply($"This group does not have a description set. To set one, type `pk;group {GroupReference(target)} description `."); + else + await ctx.Reply("This group does not have a description set."); + else if (ctx.MatchFlag("r", "raw")) + await ctx.Reply($"```\n{target.Description}\n```"); + else + await ctx.Reply(embed: new DiscordEmbedBuilder() + .WithTitle("Group description") + .WithDescription(target.Description) + .AddField("\u200B", $"To print the description with formatting, type `pk;group {GroupReference(target)} description -raw`." + + (ctx.System?.Id == target.System ? $" To clear it, type `pk;group {GroupReference(target)} description -clear`." : "")) + .Build()); + } + else + { + ctx.CheckOwnGroup(target); + + var description = ctx.RemainderOrNull().NormalizeLineEndSpacing(); + if (description.IsLongerThan(Limits.MaxDescriptionLength)) + throw Errors.DescriptionTooLongError(description.Length); + + var patch = new GroupPatch {Description = Partial.Present(description)}; + await _db.Execute(conn => conn.UpdateGroup(target.Id, patch)); + + await ctx.Reply($"{Emojis.Success} Group description changed."); + } + } + + public async Task GroupIcon(Context ctx, PKGroup target) + { + async Task ClearIcon() + { + ctx.CheckOwnGroup(target); + + await _db.Execute(c => c.UpdateGroup(target.Id, new GroupPatch {Icon = null})); + await ctx.Reply($"{Emojis.Success} Group icon cleared."); + } + + async Task SetIcon(ParsedImage img) + { + ctx.CheckOwnGroup(target); + + if (img.Url.Length > Limits.MaxUriLength) + throw Errors.InvalidUrl(img.Url); + await AvatarUtils.VerifyAvatarOrThrow(img.Url); + + await _db.Execute(c => c.UpdateGroup(target.Id, new GroupPatch {Icon = img.Url})); + + var msg = img.Source switch + { + AvatarSource.User => $"{Emojis.Success} Group icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the group icon will need to be re-set.", + AvatarSource.Url => $"{Emojis.Success} Group icon changed to the image at the given URL.", + AvatarSource.Attachment => $"{Emojis.Success} Group icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the group icon will stop working.", + _ => throw new ArgumentOutOfRangeException() + }; + + // The attachment's already right there, no need to preview it. + var hasEmbed = img.Source != AvatarSource.Attachment; + await (hasEmbed + ? ctx.Reply(msg, embed: new DiscordEmbedBuilder().WithImageUrl(img.Url).Build()) + : ctx.Reply(msg)); + } + + async Task ShowIcon() + { + if ((target.Icon?.Trim() ?? "").Length > 0) + { + var eb = new DiscordEmbedBuilder() + .WithTitle("Group icon") + .WithImageUrl(target.Icon); + + if (target.System == ctx.System?.Id) + { + eb.WithDescription($"To clear, use `pk;group {GroupReference(target)} icon -clear`."); + } + + await ctx.Reply(embed: eb.Build()); + } + else + throw new PKSyntaxError("This group does not have an icon set. Set one by attaching an image to this command, or by passing an image URL or @mention."); + } + + if (ctx.MatchClear()) + await ClearIcon(); + else if (await ctx.MatchImage() is {} img) + await SetIcon(img); + else + await ShowIcon(); + } + + public async Task ListSystemGroups(Context ctx, PKSystem system) + { + if (system == null) + { + ctx.CheckSystem(); + system = ctx.System; + } + + ctx.CheckSystemPrivacy(system, system.GroupListPrivacy); + + // TODO: integrate with the normal "search" system + await using var conn = await _db.Obtain(); + + var pctx = LookupContext.ByNonOwner; + if (ctx.MatchFlag("a", "all")) + { + if (system.Id == ctx.System.Id) + pctx = LookupContext.ByOwner; + else + throw new PKError("You do not have permission to access this information."); + } + + var groups = (await conn.QueryGroupList(system.Id)) + .Where(g => g.Visibility.CanAccess(pctx)) + .ToList(); + + if (groups.Count == 0) + { + if (system.Id == ctx.System?.Id) + await ctx.Reply("This system has no groups. To create one, use the command `pk;group new `."); + else + await ctx.Reply("This system has no groups."); + + return; + } + + var title = system.Name != null ? $"Groups of {system.Name} (`{system.Hid}`)" : $"Groups of `{system.Hid}`"; + await ctx.Paginate(groups.ToAsyncEnumerable(), groups.Count, 25, title, Renderer); + + Task Renderer(DiscordEmbedBuilder eb, IEnumerable page) + { + eb.WithSimpleLineContent(page.Select(g => + { + if (g.DisplayName != null) + return $"[`{g.Hid}`] **{g.Name}** ({g.DisplayName}) ({"member".ToQuantity(g.MemberCount)})"; + else + return $"[`{g.Hid}`] **{g.Name}** ({"member".ToQuantity(g.MemberCount)})"; + })); + eb.WithFooter($"{groups.Count} total."); + return Task.CompletedTask; + } + } + + 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 = await conn.QueryGroupMemberCount(target.Id, PrivacyLevel.Public); + + 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 {GroupReference(target)} add `!", true); + else + eb.AddField($"Members ({memberCount})", $"(see `pk;group {GroupReference(target)} 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()); + } + + public async Task AddRemoveMembers(Context ctx, PKGroup target, AddRemoveOperation op) + { + ctx.CheckOwnGroup(target); + + var members = await ParseMemberList(ctx); + + 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) + .ToHashSet(); + + if (op == AddRemoveOperation.Add) + { + var membersNotInGroup = members + .Where(m => !existingMembersInGroup.Contains(m.Id.Value)) + .Select(m => m.Id) + .Distinct() + .ToList(); + await conn.AddMembersToGroup(target.Id, membersNotInGroup); + + if (membersNotInGroup.Count == members.Count) + await ctx.Reply($"{Emojis.Success} {"members".ToQuantity(membersNotInGroup.Count)} added to group."); + else + await ctx.Reply($"{Emojis.Success} {"members".ToQuantity(membersNotInGroup.Count)} added to group ({"members".ToQuantity(members.Count - membersNotInGroup.Count)} already in group)."); + } + else if (op == AddRemoveOperation.Remove) + { + var membersInGroup = members + .Where(m => existingMembersInGroup.Contains(m.Id.Value)) + .Select(m => m.Id) + .Distinct() + .ToList(); + await conn.RemoveMembersFromGroup(target.Id, membersInGroup); + + if (membersInGroup.Count == members.Count) + await ctx.Reply($"{Emojis.Success} {"members".ToQuantity(membersInGroup.Count)} removed from group."); + else + await ctx.Reply($"{Emojis.Success} {"members".ToQuantity(membersInGroup.Count)} removed from group ({"members".ToQuantity(members.Count - membersInGroup.Count)} already not in group)."); + } + } + + public async Task ListGroupMembers(Context ctx, PKGroup target) + { + await using var conn = await _db.Obtain(); + + var targetSystem = await GetGroupSystem(ctx, target, conn); + ctx.CheckSystemPrivacy(targetSystem, target.ListPrivacy); + + var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(target.System)); + opts.GroupFilter = target.Id; + + var title = new StringBuilder($"Members of {target.DisplayName ?? target.Name} (`{target.Hid}`) in "); + if (targetSystem.Name != null) + title.Append($"{targetSystem.Name} (`{targetSystem.Hid}`)"); + else + title.Append($"`{targetSystem.Hid}`"); + if (opts.Search != null) + title.Append($" matching **{opts.Search}**"); + + await ctx.RenderMemberList(ctx.LookupContextFor(target.System), _db, target.System, title.ToString(), opts); + } + + public enum AddRemoveOperation + { + Add, + Remove + } + + private static async Task> ParseMemberList(Context ctx) + { + // TODO: move this to a context extension and share with the switch command somewhere, after branch merge? + + var members = new List(); + while (ctx.HasNext()) + { + var member = await ctx.MatchMember(); + if (member == null) + throw new PKSyntaxError(ctx.CreateMemberNotFoundError(ctx.PopArgument()));; + if (member.System != ctx.System.Id) + throw new PKError($"Member **{member.Name}** (`{member.Hid}`) is not in your own system, so you can't add it to a group."); + members.Add(member); + } + + if (members.Count == 0) + throw new PKSyntaxError("You must pass one or more members."); + return members; + } + + public async Task GroupPrivacy(Context ctx, PKGroup target, PrivacyLevel? newValueFromCommand) + { + ctx.CheckSystem().CheckOwnGroup(target); + // Display privacy settings + if (!ctx.HasNext() && newValueFromCommand == null) + { + await ctx.Reply(embed: new DiscordEmbedBuilder() + .WithTitle($"Current privacy settings for {target.Name}") + .AddField("Description", target.DescriptionPrivacy.Explanation()) + .AddField("Icon", target.IconPrivacy.Explanation()) + .AddField("Member list", target.ListPrivacy.Explanation()) + .AddField("Visibility", target.Visibility.Explanation()) + .WithDescription($"To edit privacy settings, use the command:\n> pk;group **{GroupReference(target)}** privacy **** ****\n\n- `subject` is one of `description`, `icon`, `members`, `visibility`, or `all`\n- `level` is either `public` or `private`.") + .Build()); + return; + } + + async Task SetAll(PrivacyLevel level) + { + await _db.Execute(c => c.UpdateGroup(target.Id, new GroupPatch().WithAllPrivacy(level))); + + if (level == PrivacyLevel.Private) + await ctx.Reply($"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see nothing on the group card."); + else + await ctx.Reply($"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see everything on the group card."); + } + + async Task SetLevel(GroupPrivacySubject subject, PrivacyLevel level) + { + await _db.Execute(c => c.UpdateGroup(target.Id, new GroupPatch().WithPrivacy(subject, level))); + + var subjectName = subject switch + { + GroupPrivacySubject.Description => "description privacy", + GroupPrivacySubject.Icon => "icon privacy", + GroupPrivacySubject.List => "member list", + GroupPrivacySubject.Visibility => "visibility", + _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") + }; + + var explanation = (subject, level) switch + { + (GroupPrivacySubject.Description, PrivacyLevel.Private) => "This group's description is now hidden from other systems.", + (GroupPrivacySubject.Icon, PrivacyLevel.Private) => "This group's icon is now hidden from other systems.", + (GroupPrivacySubject.Visibility, PrivacyLevel.Private) => "This group is now hidden from group lists and member cards.", + (GroupPrivacySubject.List, PrivacyLevel.Private) => "This group's member list is now hidden from other systems.", + + (GroupPrivacySubject.Description, PrivacyLevel.Public) => "This group's description is no longer hidden from other systems.", + (GroupPrivacySubject.Icon, PrivacyLevel.Public) => "This group's icon is no longer hidden from other systems.", + (GroupPrivacySubject.Visibility, PrivacyLevel.Public) => "This group is no longer hidden from group lists and member cards.", + (GroupPrivacySubject.List, PrivacyLevel.Public) => "This group's member list is no longer hidden from other systems.", + + _ => throw new InvalidOperationException($"Invalid subject/level tuple ({subject}, {level})") + }; + + await ctx.Reply($"{Emojis.Success} {target.Name}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}"); + } + + if (ctx.Match("all") || newValueFromCommand != null) + await SetAll(newValueFromCommand ?? ctx.PopPrivacyLevel()); + else + await SetLevel(ctx.PopGroupPrivacySubject(), ctx.PopPrivacyLevel()); + } + + public async Task DeleteGroup(Context ctx, PKGroup target) + { + ctx.CheckOwnGroup(target); + + await ctx.Reply($"{Emojis.Warn} Are you sure you want to delete this group? If so, reply to this message with the group's ID (`{target.Hid}`).\n**Note: this action is permanent.**"); + if (!await ctx.ConfirmWithReply(target.Hid)) + throw new PKError($"Group deletion cancelled. Note that you must reply with your group ID (`{target.Hid}`) *verbatim*."); + + await _db.Execute(conn => conn.DeleteGroup(target.Id)); + + await ctx.Reply($"{Emojis.Success} Group deleted."); + } + + private static async Task GetGroupSystem(Context ctx, PKGroup target, IPKConnection conn) + { + var system = ctx.System; + if (system?.Id == target.System) + return system; + return await conn.QuerySystem(target.System)!; + } + + private static string GroupReference(PKGroup group) + { + if (Regex.IsMatch(group.Name, "^[A-Za-z0-9\\-_]+$")) + return group.Name; + return group.Hid; + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Lists/MemberListOptions.cs b/PluralKit.Bot/Commands/Lists/MemberListOptions.cs index 6e760aed..b70ab7bf 100644 --- a/PluralKit.Bot/Commands/Lists/MemberListOptions.cs +++ b/PluralKit.Bot/Commands/Lists/MemberListOptions.cs @@ -16,6 +16,7 @@ namespace PluralKit.Bot 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; } @@ -57,7 +58,7 @@ namespace PluralKit.Bot PrivacyLevel.Public => "", // (default, no extra line needed) _ => new ArgumentOutOfRangeException($"Couldn't find readable string for privacy filter {PrivacyFilter}") }); - + return str.ToString(); } @@ -65,6 +66,7 @@ namespace PluralKit.Bot new DatabaseViewsExt.MemberListQueryOptions { PrivacyFilter = PrivacyFilter, + GroupFilter = GroupFilter, Search = Search, SearchDescription = SearchDescription }; diff --git a/PluralKit.Bot/Commands/Privacy/ContextPrivacyExt.cs b/PluralKit.Bot/Commands/Privacy/ContextPrivacyExt.cs index 8b774d25..8a0ef845 100644 --- a/PluralKit.Bot/Commands/Privacy/ContextPrivacyExt.cs +++ b/PluralKit.Bot/Commands/Privacy/ContextPrivacyExt.cs @@ -35,5 +35,14 @@ namespace PluralKit.Bot 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()}` (must be `description`, `icon`, `visibility`, or `all)."); + + ctx.PopArgument(); + return subject; + } } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index e1390c8a..143fef45 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -268,9 +268,10 @@ namespace PluralKit.Bot .WithTitle("Current privacy settings for your system") .AddField("Description", ctx.System.DescriptionPrivacy.Explanation()) .AddField("Member list", ctx.System.MemberListPrivacy.Explanation()) + .AddField("Group list", ctx.System.GroupListPrivacy.Explanation()) .AddField("Current fronter(s)", ctx.System.FrontPrivacy.Explanation()) .AddField("Front/switch history", ctx.System.FrontHistoryPrivacy.Explanation()) - .WithDescription("To edit privacy settings, use the command:\n`pk;system privacy `\n\n- `subject` is one of `description`, `list`, `front`, `fronthistory`, or `all` \n- `level` is either `public` or `private`."); + .WithDescription("To edit privacy settings, use the command:\n`pk;system privacy `\n\n- `subject` is one of `description`, `list`, `front`, `fronthistory`, `groups`, or `all` \n- `level` is either `public` or `private`."); return ctx.Reply(embed: eb.Build()); } @@ -291,6 +292,7 @@ namespace PluralKit.Bot SystemPrivacySubject.Front => "front", SystemPrivacySubject.FrontHistory => "front history", SystemPrivacySubject.MemberList => "member list", + SystemPrivacySubject.GroupList => "group list", _ => "" }; @@ -304,7 +306,7 @@ namespace PluralKit.Bot var msg = level switch { - PrivacyLevel.Private => $"All system privacy settings have been set to **{level.LevelName()}**. Other accounts will now not be able to view your member list, front history, or system description.", + PrivacyLevel.Private => $"All system privacy settings have been set to **{level.LevelName()}**. Other accounts will now not be able to view your member list, group list, front history, or system description.", PrivacyLevel.Public => $"All system privacy settings have been set to **{level.LevelName()}**. Other accounts will now be able to view everything.", _ => "" }; diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index bef64691..9058948a 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -34,6 +34,7 @@ namespace PluralKit.Bot { public static PKError NotOwnSystemError => new PKError($"You can only run this command on your own system."); public static PKError NotOwnMemberError => new PKError($"You can only run this command on your own member."); + public static PKError NotOwnGroupError => new PKError($"You can only run this command on your own group."); public static PKError NoSystemError => new PKError("You do not have a system registered with PluralKit. To create one, type `pk;system new`."); public static PKError ExistingSystemError => new PKError("You already have a system registered with PluralKit. To view it, type `pk;system`. If you'd like to delete your system and start anew, type `pk;system delete`, or if you'd like to unlink this account from it, type `pk;unlink`."); public static PKError MissingMemberError => new PKSyntaxError("You need to specify a member to run this command on."); @@ -105,7 +106,7 @@ namespace PluralKit.Bot { public static PKError ProxyNameTooShort(string name) => new PKError($"The webhook's name, `{name}`, is shorter than two characters, and thus cannot be proxied. Please change the member name or use a longer system tag."); public static PKError ProxyNameTooLong(string name) => new PKError($"The webhook's name, {name}, is too long ({name.Length} > {Limits.MaxProxyNameLength} characters), and thus cannot be proxied. Please change the member name, display name or server display name, or use a shorter system tag."); - public static PKError ProxyTagAlreadyExists(ProxyTag tagToAdd, PKMember member) => new PKError($"That member already has the proxy tag ``{tagToAdd.ProxyString.EscapeBacktickPair()}``. The member currently has these tags: {member.ProxyTagsString()}"); + public static PKError ProxyTagAlreadyExists(ProxyTag tagToAdd, PKMember member) => new PKError($"That member already has the proxy tag `` {tagToAdd.ProxyString.EscapeBacktickPair()} ``. The member currently has these tags: {member.ProxyTagsString()}"); public static PKError ProxyTagDoesNotExist(ProxyTag tagToRemove, PKMember member) => new PKError($"That member does not have the proxy tag ``{tagToRemove.ProxyString.EscapeBacktickPair()}``. The member currently has these tags: {member.ProxyTagsString()}"); public static PKError LegacyAlreadyHasProxyTag(ProxyTag requested, PKMember member) => new PKError($"This member already has more than one proxy tag set: {member.ProxyTagsString()}\nConsider using the ``pk;member {member.Hid} proxy add {requested.ProxyString.EscapeBacktickPair()}`` command instead."); public static PKError EmptyProxyTags(PKMember member) => new PKError($"The example proxy `text` is equivalent to having no proxy tags at all, since there are no symbols or brackets on either end. If you'd like to clear your proxy tags, use `pk;member {member.Hid} proxy clear`."); diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 6c4478ab..fff67417 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -33,6 +33,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/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index b027cbfd..5985a3e5 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -102,12 +102,16 @@ namespace PluralKit.Bot { // for now we just default to a blank color, yolo color = DiscordUtils.Gray; } + + await using var conn = await _db.Obtain(); - var guildSettings = guild != null ? await _db.Execute(c => c.QueryOrInsertMemberGuildConfig(guild.Id, member.Id)) : null; + var guildSettings = guild != null ? await conn.QueryOrInsertMemberGuildConfig(guild.Id, member.Id) : null; var guildDisplayName = guildSettings?.DisplayName; var avatar = guildSettings?.AvatarUrl ?? member.AvatarFor(ctx); - var proxyTagsStr = string.Join('\n', member.ProxyTags.Select(t => $"`` {t.ProxyString}``")); + var groups = (await conn.QueryMemberGroups(member.Id)).Where(g => g.Visibility.CanAccess(ctx)).ToList(); + + var proxyTagsStr = string.Join('\n', member.ProxyTags.Select(t => $"`` {t.ProxyString} ``")); var eb = new DiscordEmbedBuilder() // TODO: add URL of website when that's up @@ -124,7 +128,7 @@ namespace PluralKit.Bot { else description += "*(this member has a server-specific avatar set)*\n"; if (description != "") eb.WithDescription(description); - + if (avatar != null) eb.WithThumbnail(avatar); if (!member.DisplayName.EmptyOrNull() && member.NamePrivacy.CanAccess(ctx)) eb.AddField("Display Name", member.DisplayName.Truncate(1024), true); @@ -138,7 +142,16 @@ namespace PluralKit.Bot { // if (member.LastSwitchTime != null && m.MetadataPrivacy.CanAccess(ctx)) eb.AddField("Last switched in:", FormatTimestamp(member.LastSwitchTime.Value)); // if (!member.Color.EmptyOrNull() && member.ColorPrivacy.CanAccess(ctx)) eb.AddField("Color", $"#{member.Color}", true); if (!member.Color.EmptyOrNull()) eb.AddField("Color", $"#{member.Color}", true); - + + if (groups.Count > 0) + { + // More than 5 groups show in "compact" format without ID + var content = groups.Count > 5 + ? string.Join(", ", groups.Select(g => g.DisplayName ?? g.Name)) + : string.Join("\n", groups.Select(g => $"[`{g.Hid}`] **{g.DisplayName ?? g.Name}**")); + eb.AddField($"Groups ({groups.Count})", content.Truncate(1000)); + } + if (member.DescriptionFor(ctx) is {} desc) eb.AddField("Description", member.Description.NormalizeLineEndSpacing(), false); return eb.Build(); diff --git a/PluralKit.Core/Database/Database.cs b/PluralKit.Core/Database/Database.cs index 0071c629..cea0a72e 100644 --- a/PluralKit.Core/Database/Database.cs +++ b/PluralKit.Core/Database/Database.cs @@ -20,7 +20,7 @@ namespace PluralKit.Core internal class Database: IDatabase { private const string RootPath = "PluralKit.Core.Database"; // "resource path" root for SQL files - private const int TargetSchemaVersion = 8; + private const int TargetSchemaVersion = 9; private readonly CoreConfig _config; private readonly ILogger _logger; @@ -58,9 +58,11 @@ namespace PluralKit.Core SqlMapper.AddTypeHandler(new NumericIdHandler(i => new SystemId(i))); SqlMapper.AddTypeHandler(new NumericIdHandler(i => new MemberId(i))); SqlMapper.AddTypeHandler(new NumericIdHandler(i => new SwitchId(i))); + SqlMapper.AddTypeHandler(new NumericIdHandler(i => new GroupId(i))); SqlMapper.AddTypeHandler(new NumericIdArrayHandler(i => new SystemId(i))); SqlMapper.AddTypeHandler(new NumericIdArrayHandler(i => new MemberId(i))); SqlMapper.AddTypeHandler(new NumericIdArrayHandler(i => new SwitchId(i))); + SqlMapper.AddTypeHandler(new NumericIdArrayHandler(i => new GroupId(i))); // Register our custom types to Npgsql // Without these it'll still *work* but break at the first launch + probably cause other small issues diff --git a/PluralKit.Core/Database/Functions/functions.sql b/PluralKit.Core/Database/Functions/functions.sql index 7960f369..bdbe51e9 100644 --- a/PluralKit.Core/Database/Functions/functions.sql +++ b/PluralKit.Core/Database/Functions/functions.sql @@ -111,4 +111,14 @@ begin if not exists (select 1 from members where hid = new_hid) then return new_hid; end if; end loop; end +$$ language plpgsql volatile; + +create function find_free_group_hid() returns char(5) as $$ +declare new_hid char(5); +begin + loop + new_hid := generate_hid(); + if not exists (select 1 from groups where hid = new_hid) then return new_hid; end if; + end loop; +end $$ language plpgsql volatile; \ No newline at end of file diff --git a/PluralKit.Core/Database/Migrations/9.sql b/PluralKit.Core/Database/Migrations/9.sql new file mode 100644 index 00000000..bf409529 --- /dev/null +++ b/PluralKit.Core/Database/Migrations/9.sql @@ -0,0 +1,32 @@ +-- SCHEMA VERSION 9: 2020-xx-xx -- +-- Adds support for member groups. + +create table groups ( + id int primary key generated always as identity, + hid char(5) unique not null, + system int not null references systems(id) on delete cascade, + + name text not null, + display_name text, + description text, + icon text, + + -- Description columns follow the same pattern as usual: 1 = public, 2 = private + description_privacy integer check (description_privacy in (1, 2)) not null default 1, + icon_privacy integer check (icon_privacy in (1, 2)) not null default 1, + list_privacy integer check (list_privacy in (1, 2)) not null default 1, + visibility integer check (visibility in (1, 2)) not null default 1, + + created timestamp with time zone not null default (current_timestamp at time zone 'utc') +); + +create table group_members ( + group_id int not null references groups(id) on delete cascade, + member_id int not null references members(id) on delete cascade, + primary key (group_id, member_id) +); + +alter table systems add column group_list_privacy integer check (group_list_privacy in (1, 2)) not null default 1; +update systems set group_list_privacy = member_list_privacy; + +update info set schema_version = 9; diff --git a/PluralKit.Core/Database/Views/DatabaseViewsExt.cs b/PluralKit.Core/Database/Views/DatabaseViewsExt.cs index 5a0ef688..0171900b 100644 --- a/PluralKit.Core/Database/Views/DatabaseViewsExt.cs +++ b/PluralKit.Core/Database/Views/DatabaseViewsExt.cs @@ -11,10 +11,17 @@ namespace PluralKit.Core { public static Task> QueryCurrentFronters(this IPKConnection conn, SystemId system) => conn.QueryAsync("select * from system_fronters where system = @system", new {system}); + + public static Task> QueryGroupList(this IPKConnection conn, SystemId system) => + conn.QueryAsync("select * from group_list where system = @System", new {System = system}); public static Task> QueryMemberList(this IPKConnection conn, SystemId system, MemberListQueryOptions opts) { - StringBuilder query = new StringBuilder("select * from member_list where system = @system"); + StringBuilder query; + if (opts.GroupFilter == null) + query = new StringBuilder("select * from member_list where system = @system"); + else + query = new StringBuilder("select member_list.* from group_members inner join member_list on member_list.id = group_members.member_id where group_id = @groupFilter"); if (opts.PrivacyFilter != null) query.Append($" and member_visibility = {(int) opts.PrivacyFilter}"); @@ -35,7 +42,7 @@ namespace PluralKit.Core query.Append(")"); } - return conn.QueryAsync(query.ToString(), new {system, filter = opts.Search}); + return conn.QueryAsync(query.ToString(), new {system, filter = opts.Search, groupFilter = opts.GroupFilter}); } public struct MemberListQueryOptions @@ -44,6 +51,7 @@ namespace PluralKit.Core public string? Search; public bool SearchDescription; public LookupContext Context; + public GroupId? GroupFilter; } } } \ No newline at end of file diff --git a/PluralKit.Core/Database/Views/ListedGroup.cs b/PluralKit.Core/Database/Views/ListedGroup.cs new file mode 100644 index 00000000..fcfbc184 --- /dev/null +++ b/PluralKit.Core/Database/Views/ListedGroup.cs @@ -0,0 +1,7 @@ +namespace PluralKit.Core +{ + public class ListedGroup : PKGroup + { + public int MemberCount { get; } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Database/Views/views.sql b/PluralKit.Core/Database/Views/views.sql index 344c05b1..20f5ac1a 100644 --- a/PluralKit.Core/Database/Views/views.sql +++ b/PluralKit.Core/Database/Views/views.sql @@ -54,4 +54,10 @@ select members.*, when members.description_privacy = 1 then members.description -- Any other privacy (rn just '2'), return null description (missing case = null in SQL) end as public_description -from members; \ No newline at end of file +from members; + +create view group_list as +select groups.*, + -- Find group member count + (select count(*) from group_members where group_id = groups.id) as member_count +from groups; \ No newline at end of file diff --git a/PluralKit.Core/Database/clean.sql b/PluralKit.Core/Database/clean.sql index 21e3cbe8..3338f4bf 100644 --- a/PluralKit.Core/Database/clean.sql +++ b/PluralKit.Core/Database/clean.sql @@ -5,9 +5,11 @@ drop view if exists system_last_switch; drop view if exists system_fronters; drop view if exists member_list; +drop view if exists group_list; drop function if exists message_context; drop function if exists proxy_members; drop function if exists generate_hid; drop function if exists find_free_system_hid; -drop function if exists find_free_member_hid; \ No newline at end of file +drop function if exists find_free_member_hid; +drop function if exists find_free_group_hid; \ No newline at end of file diff --git a/PluralKit.Core/Models/GroupId.cs b/PluralKit.Core/Models/GroupId.cs new file mode 100644 index 00000000..428251da --- /dev/null +++ b/PluralKit.Core/Models/GroupId.cs @@ -0,0 +1,26 @@ +namespace PluralKit.Core +{ + public readonly struct GroupId: INumericId + { + public int Value { get; } + + public GroupId(int value) + { + Value = value; + } + + public bool Equals(GroupId other) => Value == other.Value; + + public override bool Equals(object obj) => obj is GroupId other && Equals(other); + + public override int GetHashCode() => Value; + + public static bool operator ==(GroupId left, GroupId right) => left.Equals(right); + + public static bool operator !=(GroupId left, GroupId right) => !left.Equals(right); + + public int CompareTo(GroupId other) => Value.CompareTo(other.Value); + + public override string ToString() => $"Member #{Value}"; + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/ModelQueryExt.cs b/PluralKit.Core/Models/ModelQueryExt.cs index 68ab41ad..45ee566f 100644 --- a/PluralKit.Core/Models/ModelQueryExt.cs +++ b/PluralKit.Core/Models/ModelQueryExt.cs @@ -29,6 +29,29 @@ namespace PluralKit.Core public static Task QueryMemberByHid(this IPKConnection conn, string hid) => conn.QueryFirstOrDefaultAsync("select * from members where hid = @hid", new {hid = hid.ToLowerInvariant()}); + public static Task QueryGroupByName(this IPKConnection conn, SystemId system, string name) => + conn.QueryFirstOrDefaultAsync("select * from groups where system = @System and lower(Name) = lower(@Name)", new {System = system, Name = name}); + + public static Task QueryGroupByHid(this IPKConnection conn, string hid) => + conn.QueryFirstOrDefaultAsync("select * from groups where hid = @hid", new {hid = hid.ToLowerInvariant()}); + + public static Task QueryGroupMemberCount(this IPKConnection conn, GroupId id, + PrivacyLevel? privacyFilter = null) + { + var query = new StringBuilder("select count(*) from group_members"); + if (privacyFilter != null) + query.Append(" inner join members on group_members.member_id = members.id"); + query.Append(" where group_members.group_id = @Id"); + if (privacyFilter != null) + query.Append(" and members.member_visibility = @PrivacyFilter"); + return conn.QuerySingleOrDefaultAsync(query.ToString(), new {Id = id, PrivacyFilter = privacyFilter}); + } + + public static Task> QueryMemberGroups(this IPKConnection conn, MemberId id) => + conn.QueryAsync( + "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 static Task QueryOrInsertGuildConfig(this IPKConnection conn, ulong guild) => conn.QueryFirstAsync("insert into servers (id) values (@guild) on conflict (id) do update set id = @guild returning *", new {guild}); diff --git a/PluralKit.Core/Models/PKGroup.cs b/PluralKit.Core/Models/PKGroup.cs new file mode 100644 index 00000000..7a45e900 --- /dev/null +++ b/PluralKit.Core/Models/PKGroup.cs @@ -0,0 +1,33 @@ +using NodaTime; + +#nullable enable +namespace PluralKit.Core +{ + public class PKGroup + { + public GroupId Id { get; private set; } + public string Hid { get; private set; } = null!; + public SystemId System { get; private set; } + + public string Name { get; private set; } = null!; + public string? DisplayName { get; private set; } + public string? Description { get; private set; } + public string? Icon { get; private set; } + + public PrivacyLevel DescriptionPrivacy { get; private set; } + public PrivacyLevel IconPrivacy { get; private set; } + public PrivacyLevel ListPrivacy { get; private set; } + public PrivacyLevel Visibility { get; private set; } + + public Instant Created { get; private set; } + } + + public static class PKGroupExt + { + public static string? DescriptionFor(this PKGroup group, LookupContext ctx) => + group.DescriptionPrivacy.Get(ctx, group.Description); + + public static string? IconFor(this PKGroup group, LookupContext ctx) => + group.IconPrivacy.Get(ctx, group.Icon); + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/PKSystem.cs b/PluralKit.Core/Models/PKSystem.cs index fdc6ceea..1dd5a8c9 100644 --- a/PluralKit.Core/Models/PKSystem.cs +++ b/PluralKit.Core/Models/PKSystem.cs @@ -22,6 +22,7 @@ namespace PluralKit.Core { public PrivacyLevel MemberListPrivacy { get;} public PrivacyLevel FrontPrivacy { get; } public PrivacyLevel FrontHistoryPrivacy { get; } + public PrivacyLevel GroupListPrivacy { get; } [JsonIgnore] public DateTimeZone Zone => DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz); } diff --git a/PluralKit.Core/Models/Patch/GroupPatch.cs b/PluralKit.Core/Models/Patch/GroupPatch.cs new file mode 100644 index 00000000..2f154c90 --- /dev/null +++ b/PluralKit.Core/Models/Patch/GroupPatch.cs @@ -0,0 +1,26 @@ +#nullable enable +namespace PluralKit.Core +{ + public class GroupPatch: PatchObject + { + public Partial Name { get; set; } + public Partial DisplayName { get; set; } + public Partial Description { get; set; } + public Partial Icon { get; set; } + + public Partial DescriptionPrivacy { get; set; } + public Partial IconPrivacy { get; set; } + public Partial ListPrivacy { get; set; } + public Partial Visibility { get; set; } + + public override UpdateQueryBuilder Apply(UpdateQueryBuilder b) => b + .With("name", Name) + .With("display_name", DisplayName) + .With("description", Description) + .With("icon", Icon) + .With("description_privacy", DescriptionPrivacy) + .With("icon_privacy", IconPrivacy) + .With("list_privacy", ListPrivacy) + .With("visibility", Visibility); + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/ModelPatchExt.cs b/PluralKit.Core/Models/Patch/ModelPatchExt.cs index 05b0a471..f18b0ba7 100644 --- a/PluralKit.Core/Models/Patch/ModelPatchExt.cs +++ b/PluralKit.Core/Models/Patch/ModelPatchExt.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using Dapper; @@ -60,5 +62,37 @@ namespace PluralKit.Core .Build(); return conn.ExecuteAsync(query, pms); } + + public static Task CreateGroup(this IPKConnection conn, SystemId system, string name) => + conn.QueryFirstAsync( + "insert into groups (hid, system, name) values (find_free_group_hid(), @System, @Name) returning *", + new {System = system, Name = name}); + + public static Task UpdateGroup(this IPKConnection conn, GroupId id, GroupPatch patch) + { + var (query, pms) = patch.Apply(UpdateQueryBuilder.Update("groups", "id = @id")) + .WithConstant("id", id) + .Build("returning *"); + return conn.QueryFirstAsync(query, pms); + } + + public static Task DeleteGroup(this IPKConnection conn, GroupId group) => + conn.ExecuteAsync("delete from groups where id = @Id", new {Id = group }); + + public static async Task AddMembersToGroup(this IPKConnection conn, GroupId group, IEnumerable members) + { + await using var w = conn.BeginBinaryImport("copy group_members (group_id, member_id) from stdin (format binary)"); + foreach (var member in members) + { + await w.StartRowAsync(); + await w.WriteAsync(group.Value); + await w.WriteAsync(member.Value); + } + await w.CompleteAsync(); + } + + public static Task RemoveMembersFromGroup(this IPKConnection conn, GroupId group, IEnumerable members) => + conn.ExecuteAsync("delete from group_members where group_id = @Group and member_id = any(@Members)", + new {Group = group, Members = members.ToArray() }); } } \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/SystemPatch.cs b/PluralKit.Core/Models/Patch/SystemPatch.cs index aecd160f..d574e8e2 100644 --- a/PluralKit.Core/Models/Patch/SystemPatch.cs +++ b/PluralKit.Core/Models/Patch/SystemPatch.cs @@ -11,6 +11,7 @@ namespace PluralKit.Core public Partial UiTz { get; set; } public Partial DescriptionPrivacy { get; set; } public Partial MemberListPrivacy { get; set; } + public Partial GroupListPrivacy { get; set; } public Partial FrontPrivacy { get; set; } public Partial FrontHistoryPrivacy { get; set; } public Partial PingsEnabled { get; set; } @@ -24,6 +25,7 @@ namespace PluralKit.Core .With("ui_tz", UiTz) .With("description_privacy", DescriptionPrivacy) .With("member_list_privacy", MemberListPrivacy) + .With("group_list_privacy", GroupListPrivacy) .With("front_privacy", FrontPrivacy) .With("front_history_privacy", FrontHistoryPrivacy) .With("pings_enabled", PingsEnabled); diff --git a/PluralKit.Core/Models/Privacy/GroupPrivacySubject.cs b/PluralKit.Core/Models/Privacy/GroupPrivacySubject.cs new file mode 100644 index 00000000..90691d48 --- /dev/null +++ b/PluralKit.Core/Models/Privacy/GroupPrivacySubject.cs @@ -0,0 +1,72 @@ +using System; + +namespace PluralKit.Core +{ + public enum GroupPrivacySubject + { + Description, + Icon, + List, + Visibility + } + + public static class GroupPrivacyUtils + { + public static GroupPatch WithPrivacy(this GroupPatch group, GroupPrivacySubject subject, PrivacyLevel level) + { + // what do you mean switch expressions can't be statements >.> + _ = subject switch + { + GroupPrivacySubject.Description => group.DescriptionPrivacy = level, + GroupPrivacySubject.Icon => group.IconPrivacy = level, + GroupPrivacySubject.List => group.ListPrivacy = level, + GroupPrivacySubject.Visibility => group.Visibility = level, + _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") + }; + + return group; + } + + public static GroupPatch WithAllPrivacy(this GroupPatch member, PrivacyLevel level) + { + foreach (var subject in Enum.GetValues(typeof(GroupPrivacySubject))) + member.WithPrivacy((GroupPrivacySubject) subject, level); + return member; + } + + public static bool TryParseGroupPrivacy(string input, out GroupPrivacySubject subject) + { + switch (input.ToLowerInvariant()) + { + case "description": + case "desc": + case "text": + case "info": + subject = GroupPrivacySubject.Description; + break; + case "avatar": + case "pfp": + case "pic": + case "icon": + subject = GroupPrivacySubject.Icon; + break; + case "visibility": + case "hidden": + case "shown": + case "visible": + subject = GroupPrivacySubject.Visibility; + break; + case "list": + case "listing": + case "members": + subject = GroupPrivacySubject.List; + break; + default: + subject = default; + return false; + } + + return true; + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/Privacy/SystemPrivacySubject.cs b/PluralKit.Core/Models/Privacy/SystemPrivacySubject.cs index 1b426d69..8fcf3efe 100644 --- a/PluralKit.Core/Models/Privacy/SystemPrivacySubject.cs +++ b/PluralKit.Core/Models/Privacy/SystemPrivacySubject.cs @@ -6,6 +6,7 @@ namespace PluralKit.Core { Description, MemberList, + GroupList, Front, FrontHistory } @@ -21,6 +22,7 @@ namespace PluralKit.Core SystemPrivacySubject.Front => system.FrontPrivacy = level, SystemPrivacySubject.FrontHistory => system.FrontHistoryPrivacy = level, SystemPrivacySubject.MemberList => system.MemberListPrivacy = level, + SystemPrivacySubject.GroupList => system.GroupListPrivacy = level, _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") }; @@ -61,6 +63,10 @@ namespace PluralKit.Core case "fh": subject = SystemPrivacySubject.FrontHistory; break; + case "groups": + case "gs": + subject = SystemPrivacySubject.GroupList; + break; default: subject = default; return false; diff --git a/PluralKit.Core/Utils/Limits.cs b/PluralKit.Core/Utils/Limits.cs index 8c863adb..0297ae33 100644 --- a/PluralKit.Core/Utils/Limits.cs +++ b/PluralKit.Core/Utils/Limits.cs @@ -7,8 +7,10 @@ namespace PluralKit.Core { public static readonly int MaxSystemTagLength = MaxProxyNameLength - 1; public static readonly int MaxMemberCount = 1500; public static readonly int MaxMembersWarnThreshold = MaxMemberCount - 50; + public static readonly int MaxGroupCount = 100; // TODO: up to 200+? public static readonly int MaxDescriptionLength = 1000; public static readonly int MaxMemberNameLength = 100; // Fair bit larger than MaxProxyNameLength for bookkeeping + public static readonly int MaxGroupNameLength = 100; public static readonly int MaxPronounsLength = 100; public static readonly int MaxUriLength = 256; // May need to be set higher, I know there are URLs longer than this in prod (they can rehost, I guess...) public static readonly long AvatarFileSizeLimit = 1024 * 1024; diff --git a/docs/content/user-guide.md b/docs/content/user-guide.md index 65644b18..425636c3 100644 --- a/docs/content/user-guide.md +++ b/docs/content/user-guide.md @@ -415,6 +415,62 @@ To look at the per-member breakdown of the front over a given time period, use t Note that in cases of switches with multiple members, each involved member will have the full length of the switch counted towards it. This means that the percentages may add up to over 100%. +## Member groups +PluralKit allows you to categorize system members in different **groups**. +You can add members to a group, and each member can be in multiple groups. +The groups a member is in will show on the group card. + +### Creating a new group +To create a new group, use the `pk;group new` command: + + pk;group new MyGroup + +This will create a new group. Groups all have a 5-letter ID, similar to systems and members. + +### Adding and removing members to groups +To add a member to a group, use the `pk;group add` command, eg: + + pk;group MyGroup add Craig + +You can add multiple members to a group by separating them with spaces, eg: + + pk;group MyGroup add Bob John Charlie + +Similarly, you can remove members from a group, eg: + + pk;group MyGroup remove Bob Craig + +### Listing members in a group +To list all the members in a group, use the `pk;group list` command. +The syntax works the same as `pk;system list`, and also allows searching and sorting, eg: + + pk;group MyGroup list + pk;group MyGroup list --by-message-count jo + +### Listing all your groups +In the same vein, you can list all the groups in your system with the `pk;group list` command: + + pk;group list + +### Group name, description, icon, delete +(TODO: write this better) + +Groups can be renamed: + + pk;group MyGroup rename SuperCoolGroup + +Groups can have icons that show in on the group card: + + pk;group MyGroup icon https://my.link.to/image.png + +Groups can have descriptions: + + pk;group MyGroup description This is my cool group description! :) + +Groups can be deleted: + + pk;group MyGroup delete + ## Privacy There are various reasons you may not want information about your system or your members to be public. As such, there are a few controls to manage which information is publicly accessible or not.