From 253ae43c7f94389b00f3b878a004941723bf4382 Mon Sep 17 00:00:00 2001 From: Ske Date: Mon, 29 Jun 2020 23:51:12 +0200 Subject: [PATCH 01/17] Add super basic group model/command --- PluralKit.Bot/CommandSystem/Context.cs | 3 + .../ContextEntityArgumentsExt.cs | 20 +++++++ PluralKit.Bot/Commands/CommandTree.cs | 17 ++++++ PluralKit.Bot/Commands/Groups.cs | 58 +++++++++++++++++++ PluralKit.Bot/Modules.cs | 1 + PluralKit.Core/Database/Database.cs | 4 +- .../Database/Functions/functions.sql | 10 ++++ PluralKit.Core/Database/Migrations/9.sql | 17 ++++++ PluralKit.Core/Database/clean.sql | 3 +- PluralKit.Core/Models/GroupId.cs | 26 +++++++++ PluralKit.Core/Models/ModelQueryExt.cs | 6 ++ PluralKit.Core/Models/PKGroup.cs | 17 ++++++ PluralKit.Core/Models/Patch/GroupPatch.cs | 13 +++++ PluralKit.Core/Models/Patch/ModelPatchExt.cs | 5 ++ PluralKit.Core/Utils/Limits.cs | 1 + 15 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 PluralKit.Bot/Commands/Groups.cs create mode 100644 PluralKit.Core/Database/Migrations/9.sql create mode 100644 PluralKit.Core/Models/GroupId.cs create mode 100644 PluralKit.Core/Models/PKGroup.cs create mode 100644 PluralKit.Core/Models/Patch/GroupPatch.cs 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/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs index 72a8bb1b..ed8d31a8 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 (await conn.QueryGroupByName(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) { diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index 08fd4a6e..efc75548 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -45,6 +45,8 @@ 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 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"); @@ -97,6 +99,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")) @@ -312,6 +316,19 @@ 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)); + + if (await ctx.MatchGroup() is {} group) + { + // Commands with group argument + await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, group)); + } + } + 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..898d2193 --- /dev/null +++ b/PluralKit.Bot/Commands/Groups.cs @@ -0,0 +1,58 @@ +using System.Threading.Tasks; + +using DSharpPlus.Entities; + +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(); + + 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.MaxMemberNameLength} characters)."); + + await using var conn = await _db.Obtain(); + var newGroup = await conn.CreateGroup(ctx.System.Id, groupName); + + await ctx.Reply($"{Emojis.Success} Group \"**{groupName}**\" (`{newGroup.Hid}`) registered!\nYou can now start adding members to the group:\n- **pk;group {newGroup.Hid} add **"); + } + + public async Task ShowGroupCard(Context ctx, PKGroup target) + { + await using var conn = await _db.Obtain(); + + var system = await GetGroupSystem(ctx, target, conn); + + var nameField = target.Name; + if (system.Name != null) + nameField = $"{nameField} ({system.Name})"; + + var eb = new DiscordEmbedBuilder() + .WithAuthor(nameField) + .WithDescription(target.Description) + .WithFooter($"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}"); + + await ctx.Reply(embed: eb.Build()); + } + + 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)!; + } + } +} \ No newline at end of file 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.Core/Database/Database.cs b/PluralKit.Core/Database/Database.cs index 09e6ff20..9fe48710 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..47c7429c --- /dev/null +++ b/PluralKit.Core/Database/Migrations/9.sql @@ -0,0 +1,17 @@ +-- SCHEMA VERSION 9: 2020-xx-xx -- + +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, + description text, + 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 +); + +update info set schema_version = 9; diff --git a/PluralKit.Core/Database/clean.sql b/PluralKit.Core/Database/clean.sql index 21e3cbe8..f950c6ec 100644 --- a/PluralKit.Core/Database/clean.sql +++ b/PluralKit.Core/Database/clean.sql @@ -10,4 +10,5 @@ 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..abe06644 100644 --- a/PluralKit.Core/Models/ModelQueryExt.cs +++ b/PluralKit.Core/Models/ModelQueryExt.cs @@ -29,6 +29,12 @@ 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, string name) => + conn.QueryFirstOrDefaultAsync("select * from groups where lower(name) = lower(@name)", new {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 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..276157dc --- /dev/null +++ b/PluralKit.Core/Models/PKGroup.cs @@ -0,0 +1,17 @@ +using NodaTime; + +#nullable enable +namespace PluralKit.Core +{ + public class PKGroup + { + public GroupId Id { get; } + public string Hid { get; } = null!; + public SystemId System { get; } + + public string Name { get; } = null!; + public string? Description { get; } + + public Instant Created { get; } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/GroupPatch.cs b/PluralKit.Core/Models/Patch/GroupPatch.cs new file mode 100644 index 00000000..5c4143fd --- /dev/null +++ b/PluralKit.Core/Models/Patch/GroupPatch.cs @@ -0,0 +1,13 @@ +#nullable enable +namespace PluralKit.Core +{ + public class GroupPatch: PatchObject + { + public Partial Name { get; set; } + public Partial Description { get; set; } + + public override UpdateQueryBuilder Apply(UpdateQueryBuilder b) => b + .With("name", Name) + .With("description", Description); + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/ModelPatchExt.cs b/PluralKit.Core/Models/Patch/ModelPatchExt.cs index 05b0a471..e2f882d2 100644 --- a/PluralKit.Core/Models/Patch/ModelPatchExt.cs +++ b/PluralKit.Core/Models/Patch/ModelPatchExt.cs @@ -60,5 +60,10 @@ 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}); } } \ No newline at end of file diff --git a/PluralKit.Core/Utils/Limits.cs b/PluralKit.Core/Utils/Limits.cs index 8c863adb..afcf0dd8 100644 --- a/PluralKit.Core/Utils/Limits.cs +++ b/PluralKit.Core/Utils/Limits.cs @@ -9,6 +9,7 @@ namespace PluralKit.Core { public static readonly int MaxMembersWarnThreshold = MaxMemberCount - 50; 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; From 6c5cb8cea76cef69ad68f255dafe84b122e3c497 Mon Sep 17 00:00:00 2001 From: Ske Date: Mon, 6 Jul 2020 19:50:39 +0200 Subject: [PATCH 02/17] Add group name/description/list commands --- .../CommandSystem/ContextChecksExt.cs | 7 ++ .../ContextEntityArgumentsExt.cs | 15 +++ PluralKit.Bot/Commands/CommandTree.cs | 21 +++- PluralKit.Bot/Commands/Groups.cs | 101 +++++++++++++++++- PluralKit.Bot/Errors.cs | 3 +- PluralKit.Core/Models/ModelQueryExt.cs | 3 + PluralKit.Core/Models/Patch/ModelPatchExt.cs | 8 ++ 7 files changed, 152 insertions(+), 6 deletions(-) diff --git a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs index 46fb6499..86d62f59 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.NotOwnMemberError; + 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 ed8d31a8..89952702 100644 --- a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs @@ -133,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 efc75548..7947b390 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -47,6 +47,9 @@ namespace PluralKit.Bot 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 GroupRename = new Command("group rename", "group name ", "Renames a group"); + public static Command GroupDesc = new Command("group description", "group description [description]", "Changes a group's description"); 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"); @@ -321,12 +324,24 @@ namespace PluralKit.Bot // Commands with no group argument if (ctx.Match("n", "new")) await ctx.Execute(GroupNew, g => g.CreateGroup(ctx)); - - if (await ctx.MatchGroup() is {} group) + else if (ctx.Match("list", "l")) + await ctx.Execute(GroupList, g => g.ListSystemGroups(ctx, null)); + else if (await ctx.MatchGroup() is {} target) { // Commands with group argument - await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, group)); + if (ctx.Match("rename", "name", "changename", "setname")) + await ctx.Execute(GroupRename, g => g.RenameGroup(ctx, target)); + else if (ctx.Match("description", "info", "bio", "text", "desc")) + await ctx.Execute(GroupDesc, g => g.GroupDescription(ctx, target)); + else if (!ctx.HasNext()) + await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, target)); + else + await PrintCommandNotFoundError(ctx, GroupInfo, GroupRename, GroupDesc); } + else if (!ctx.HasNext()) + await PrintCommandNotFoundError(ctx, GroupInfo, GroupList, GroupNew, GroupRename, GroupDesc); + else + await ctx.Reply($"{Emojis.Error} {ctx.CreateGroupNotFoundError(ctx.PopArgument())}"); } private async Task HandleSwitchCommand(Context ctx) diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 898d2193..14af6b6a 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -1,4 +1,7 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; using DSharpPlus.Entities; @@ -29,6 +32,100 @@ namespace PluralKit.Bot await ctx.Reply($"{Emojis.Success} Group \"**{groupName}**\" (`{newGroup.Hid}`) registered!\nYou can now start adding members to the group:\n- **pk;group {newGroup.Hid} add **"); } + public async Task RenameGroup(Context ctx, PKGroup target) + { + ctx.CheckOwnGroup(target); + + 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(); + 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 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 {target.Hid} 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 {target.Hid} description -raw`." + + (ctx.System?.Id == target.System ? $" To clear it, type `pk;group {target.Hid} 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 ListSystemGroups(Context ctx, PKSystem system) + { + if (system == null) + { + ctx.CheckSystem(); + system = ctx.System; + } + + // TODO: integrate with the normal "search" system + await using var conn = await _db.Obtain(); + + var groups = (await conn.QueryGroupsInSystem(system.Id)).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) + { + var sb = new StringBuilder(); + foreach (var g in page) + { + sb.Append($"[`{g.Hid}`] **{g.Name}**\n"); + } + + eb.WithDescription(sb.ToString()); + eb.WithFooter($"{groups.Count} total"); + return Task.CompletedTask; + } + } + public async Task ShowGroupCard(Context ctx, PKGroup target) { await using var conn = await _db.Obtain(); @@ -41,7 +138,7 @@ namespace PluralKit.Bot var eb = new DiscordEmbedBuilder() .WithAuthor(nameField) - .WithDescription(target.Description) + .AddField("Description", target.Description) .WithFooter($"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}"); await ctx.Reply(embed: eb.Build()); diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 2b6c47ab..58c673a6 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.Core/Models/ModelQueryExt.cs b/PluralKit.Core/Models/ModelQueryExt.cs index abe06644..a99d58f8 100644 --- a/PluralKit.Core/Models/ModelQueryExt.cs +++ b/PluralKit.Core/Models/ModelQueryExt.cs @@ -35,6 +35,9 @@ namespace PluralKit.Core public static Task QueryGroupByHid(this IPKConnection conn, string hid) => conn.QueryFirstOrDefaultAsync("select * from groups where hid = @hid", new {hid = hid.ToLowerInvariant()}); + public static Task> QueryGroupsInSystem(this IPKConnection conn, SystemId system) => + conn.QueryAsync("select * from groups where system = @System", new {System = system}); + 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/Patch/ModelPatchExt.cs b/PluralKit.Core/Models/Patch/ModelPatchExt.cs index e2f882d2..f98fa94c 100644 --- a/PluralKit.Core/Models/Patch/ModelPatchExt.cs +++ b/PluralKit.Core/Models/Patch/ModelPatchExt.cs @@ -65,5 +65,13 @@ namespace PluralKit.Core 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); + } } } \ No newline at end of file From 0f4c40b3449157dace240d55c2d838f30396ddda Mon Sep 17 00:00:00 2001 From: Ske Date: Tue, 7 Jul 2020 15:28:53 +0200 Subject: [PATCH 03/17] Add group add/remove commands --- PluralKit.Bot/Commands/CommandTree.cs | 6 ++++ PluralKit.Bot/Commands/Groups.cs | 38 ++++++++++++++++++++ PluralKit.Core/Models/Patch/ModelPatchExt.cs | 20 ++++++++++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index 7947b390..dcc5904a 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -50,6 +50,8 @@ namespace PluralKit.Bot public static Command GroupList = new Command("group list", "group list", "Lists all groups in this system"); public static Command GroupRename = new Command("group rename", "group name ", "Renames a group"); 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 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"); @@ -333,6 +335,10 @@ namespace PluralKit.Bot await ctx.Execute(GroupRename, g => g.RenameGroup(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.HasNext()) await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, target)); else diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 14af6b6a..a0e32ded 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -144,6 +144,44 @@ namespace PluralKit.Bot await ctx.Reply(embed: eb.Build()); } + public async Task AddRemoveMembers(Context ctx, PKGroup target, AddRemoveOperation op) + { + ctx.CheckOwnGroup(target); + + // Parse all arguments + 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 != target.System) + 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."); + + await using var conn = await _db.Obtain(); + if (op == AddRemoveOperation.Add) + { + await conn.AddMembersToGroup(target.Id, members.Select(m => m.Id)); + await ctx.Reply($"{Emojis.Success} Members added to group."); + } + else if (op == AddRemoveOperation.Remove) + { + await conn.RemoveMembersFromGroup(target.Id, members.Select(m => m.Id)); + await ctx.Reply($"{Emojis.Success} Members removed from group."); + } + } + + public enum AddRemoveOperation + { + Add, + Remove + } + private static async Task GetGroupSystem(Context ctx, PKGroup target, IPKConnection conn) { var system = ctx.System; diff --git a/PluralKit.Core/Models/Patch/ModelPatchExt.cs b/PluralKit.Core/Models/Patch/ModelPatchExt.cs index f98fa94c..b7074d92 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; @@ -73,5 +75,21 @@ namespace PluralKit.Core .Build("returning *"); return conn.QueryFirstAsync(query, pms); } + + 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 From 8a28d836c73f152dfe23d99e5f6f5f4b915f3b7f Mon Sep 17 00:00:00 2001 From: Ske Date: Tue, 7 Jul 2020 19:34:23 +0200 Subject: [PATCH 04/17] Add group member list command --- PluralKit.Bot/Commands/CommandTree.cs | 3 +++ PluralKit.Bot/Commands/Groups.cs | 22 +++++++++++++++++++ .../Commands/Lists/MemberListOptions.cs | 4 +++- .../Database/Views/DatabaseViewsExt.cs | 9 ++++++-- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index dcc5904a..f219ea18 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -48,6 +48,7 @@ namespace PluralKit.Bot 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 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"); @@ -339,6 +340,8 @@ namespace PluralKit.Bot 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.HasNext()) await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, target)); else diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index a0e32ded..8c1631d8 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -5,6 +5,8 @@ using System.Threading.Tasks; using DSharpPlus.Entities; +using NodaTime; + using PluralKit.Core; namespace PluralKit.Bot @@ -176,6 +178,26 @@ namespace PluralKit.Bot } } + 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, targetSystem.MemberListPrivacy); + + var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(target.System)); + opts.GroupFilter = target.Id; + + var title = new StringBuilder($"Members of {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, diff --git a/PluralKit.Bot/Commands/Lists/MemberListOptions.cs b/PluralKit.Bot/Commands/Lists/MemberListOptions.cs index 9e00b226..5708c6a5 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; } @@ -55,7 +56,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(); } @@ -63,6 +64,7 @@ namespace PluralKit.Bot new DatabaseViewsExt.MemberListQueryOptions { PrivacyFilter = PrivacyFilter, + GroupFilter = GroupFilter, Search = Search, SearchDescription = SearchDescription }; diff --git a/PluralKit.Core/Database/Views/DatabaseViewsExt.cs b/PluralKit.Core/Database/Views/DatabaseViewsExt.cs index 5a0ef688..c399a28d 100644 --- a/PluralKit.Core/Database/Views/DatabaseViewsExt.cs +++ b/PluralKit.Core/Database/Views/DatabaseViewsExt.cs @@ -14,7 +14,11 @@ namespace PluralKit.Core 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 +39,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 +48,7 @@ namespace PluralKit.Core public string? Search; public bool SearchDescription; public LookupContext Context; + public GroupId? GroupFilter; } } } \ No newline at end of file From 18cb6785e9d9b9904a95f99cbb8c846f0b3ba5e8 Mon Sep 17 00:00:00 2001 From: Ske Date: Tue, 7 Jul 2020 19:34:44 +0200 Subject: [PATCH 05/17] Extract member list parsing to a separate method --- PluralKit.Bot/Commands/Groups.cs | 37 +++++++++++++++++++------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 8c1631d8..2faec364 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -149,22 +149,9 @@ namespace PluralKit.Bot public async Task AddRemoveMembers(Context ctx, PKGroup target, AddRemoveOperation op) { ctx.CheckOwnGroup(target); + + var members = await ParseMemberList(ctx); - // Parse all arguments - 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 != target.System) - 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."); - await using var conn = await _db.Obtain(); if (op == AddRemoveOperation.Add) { @@ -204,6 +191,26 @@ namespace PluralKit.Bot 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; + } + private static async Task GetGroupSystem(Context ctx, PKGroup target, IPKConnection conn) { var system = ctx.System; From 9d5be07f0c4b7ae2be671a510ce239b4c990844c Mon Sep 17 00:00:00 2001 From: Ske Date: Tue, 7 Jul 2020 19:45:26 +0200 Subject: [PATCH 06/17] Add group icon and privacy to the database schema --- PluralKit.Core/Database/Migrations/9.sql | 9 +++++++++ PluralKit.Core/Models/PKGroup.cs | 5 +++++ PluralKit.Core/Models/Patch/GroupPatch.cs | 11 ++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/PluralKit.Core/Database/Migrations/9.sql b/PluralKit.Core/Database/Migrations/9.sql index 47c7429c..60444326 100644 --- a/PluralKit.Core/Database/Migrations/9.sql +++ b/PluralKit.Core/Database/Migrations/9.sql @@ -1,11 +1,20 @@ -- 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, 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, + 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') ); diff --git a/PluralKit.Core/Models/PKGroup.cs b/PluralKit.Core/Models/PKGroup.cs index 276157dc..e553b902 100644 --- a/PluralKit.Core/Models/PKGroup.cs +++ b/PluralKit.Core/Models/PKGroup.cs @@ -11,6 +11,11 @@ namespace PluralKit.Core public string Name { get; } = null!; public string? Description { get; } + public string? Icon { get; } + + public PrivacyLevel DescriptionPrivacy { get; } + public PrivacyLevel IconPrivacy { get; } + public PrivacyLevel Visibility { get; } public Instant Created { get; } } diff --git a/PluralKit.Core/Models/Patch/GroupPatch.cs b/PluralKit.Core/Models/Patch/GroupPatch.cs index 5c4143fd..02281d8c 100644 --- a/PluralKit.Core/Models/Patch/GroupPatch.cs +++ b/PluralKit.Core/Models/Patch/GroupPatch.cs @@ -5,9 +5,18 @@ namespace PluralKit.Core { public Partial Name { get; set; } public Partial Description { get; set; } + public Partial Icon { get; set; } + + public Partial DescriptionPrivacy { get; set; } + public Partial IconPrivacy { get; set; } + public Partial Visibility { get; set; } public override UpdateQueryBuilder Apply(UpdateQueryBuilder b) => b .With("name", Name) - .With("description", Description); + .With("description", Description) + .With("icon", Icon) + .With("description_privacy", DescriptionPrivacy) + .With("icon_privacy", IconPrivacy) + .With("visibility", Visibility); } } \ No newline at end of file From 0d04be6540e6bcfa3db6f81822ca9e1466a9b1b3 Mon Sep 17 00:00:00 2001 From: Ske Date: Wed, 8 Jul 2020 00:56:06 +0200 Subject: [PATCH 07/17] Fix error showing group card with no description --- PluralKit.Bot/Commands/Groups.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 2faec364..9c3e75a4 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -140,9 +140,11 @@ namespace PluralKit.Bot var eb = new DiscordEmbedBuilder() .WithAuthor(nameField) - .AddField("Description", target.Description) .WithFooter($"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}"); + if (target.Description != null) + eb.AddField("Description", target.Description); + await ctx.Reply(embed: eb.Build()); } From ec9ee5c794820d5924b4627d5ad77e18fbb2ff69 Mon Sep 17 00:00:00 2001 From: Ske Date: Sat, 18 Jul 2020 13:19:53 +0200 Subject: [PATCH 08/17] Show member count on group card --- PluralKit.Bot/Commands/Groups.cs | 8 +++++++- PluralKit.Core/Models/ModelQueryExt.cs | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 9c3e75a4..2580eb3f 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -31,7 +31,7 @@ namespace PluralKit.Bot await using var conn = await _db.Obtain(); var newGroup = await conn.CreateGroup(ctx.System.Id, groupName); - await ctx.Reply($"{Emojis.Success} Group \"**{groupName}**\" (`{newGroup.Hid}`) registered!\nYou can now start adding members to the group:\n- **pk;group {newGroup.Hid} add **"); + await ctx.Reply($"{Emojis.Success} Group \"**{groupName}**\" (`{newGroup.Hid}`) registered!\nYou can now start adding members to the group like this:\n> **pk;group `{newGroup.Hid}` add `member1` `member2...`**"); } public async Task RenameGroup(Context ctx, PKGroup target) @@ -133,6 +133,7 @@ namespace PluralKit.Bot await using var conn = await _db.Obtain(); var system = await GetGroupSystem(ctx, target, conn); + var memberCount = await conn.QueryGroupMemberCount(target.Id, PrivacyLevel.Public); var nameField = target.Name; if (system.Name != null) @@ -142,6 +143,11 @@ namespace PluralKit.Bot .WithAuthor(nameField) .WithFooter($"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}"); + if (memberCount == 0) + eb.AddField("Members (0)", $"Add one with `pk;group {target.Hid} add `!", true); + else + eb.AddField($"Members ({memberCount})", $"(see `pk;group {target.Hid} list`)", true); + if (target.Description != null) eb.AddField("Description", target.Description); diff --git a/PluralKit.Core/Models/ModelQueryExt.cs b/PluralKit.Core/Models/ModelQueryExt.cs index a99d58f8..9b751759 100644 --- a/PluralKit.Core/Models/ModelQueryExt.cs +++ b/PluralKit.Core/Models/ModelQueryExt.cs @@ -38,6 +38,18 @@ namespace PluralKit.Core public static Task> QueryGroupsInSystem(this IPKConnection conn, SystemId system) => conn.QueryAsync("select * from groups where system = @System", new {System = system}); + 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(" left 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 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}); From f47d366c8df7a8359c60de1e100521404f23f467 Mon Sep 17 00:00:00 2001 From: Ske Date: Sat, 18 Jul 2020 13:26:36 +0200 Subject: [PATCH 09/17] Show member groups on member card --- PluralKit.Bot/Services/EmbedService.cs | 11 +++++++++-- PluralKit.Core/Models/ModelQueryExt.cs | 7 ++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index b027cbfd..50de24e2 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)).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 @@ -125,6 +129,9 @@ namespace PluralKit.Bot { description += "*(this member has a server-specific avatar set)*\n"; if (description != "") eb.WithDescription(description); + if (groups.Count > 0) + eb.AddField($"Groups ({groups.Count})", string.Join("\n", groups.Select(g => $"[`{g.Hid}`] **{g.Name}**")).Truncate(1000)); + if (avatar != null) eb.WithThumbnail(avatar); if (!member.DisplayName.EmptyOrNull() && member.NamePrivacy.CanAccess(ctx)) eb.AddField("Display Name", member.DisplayName.Truncate(1024), true); diff --git a/PluralKit.Core/Models/ModelQueryExt.cs b/PluralKit.Core/Models/ModelQueryExt.cs index 9b751759..8eaa3da2 100644 --- a/PluralKit.Core/Models/ModelQueryExt.cs +++ b/PluralKit.Core/Models/ModelQueryExt.cs @@ -43,13 +43,18 @@ namespace PluralKit.Core { var query = new StringBuilder("select count(*) from group_members"); if (privacyFilter != null) - query.Append(" left join members on group_members.member_id = members.id"); + 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}); From 47d5ad00049d6b8c60b28a22f129dfaf32f6f60c Mon Sep 17 00:00:00 2001 From: Ske Date: Sat, 18 Jul 2020 13:30:54 +0200 Subject: [PATCH 10/17] Enforce group count limit on creation --- PluralKit.Bot/Commands/Groups.cs | 8 +++++++- PluralKit.Core/Utils/Limits.cs | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 2580eb3f..d381dcdb 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -3,6 +3,8 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using Dapper; + using DSharpPlus.Entities; using NodaTime; @@ -29,8 +31,12 @@ namespace PluralKit.Bot throw new PKError($"Group name too long ({groupName.Length}/{Limits.MaxMemberNameLength} characters)."); await using var conn = await _db.Obtain(); - var newGroup = await conn.CreateGroup(ctx.System.Id, groupName); + var existingGroupCount = await conn.QuerySingleAsync("select count(*) from groups where system = @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."); + + var newGroup = await conn.CreateGroup(ctx.System.Id, groupName); await ctx.Reply($"{Emojis.Success} Group \"**{groupName}**\" (`{newGroup.Hid}`) registered!\nYou can now start adding members to the group like this:\n> **pk;group `{newGroup.Hid}` add `member1` `member2...`**"); } diff --git a/PluralKit.Core/Utils/Limits.cs b/PluralKit.Core/Utils/Limits.cs index afcf0dd8..769acb3a 100644 --- a/PluralKit.Core/Utils/Limits.cs +++ b/PluralKit.Core/Utils/Limits.cs @@ -7,6 +7,7 @@ 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 = 50; // TODO: up to 100+? 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; From 5e28e0aba16f81c340e888de03d55a89c58aee05 Mon Sep 17 00:00:00 2001 From: Ske Date: Sat, 18 Jul 2020 13:53:02 +0200 Subject: [PATCH 11/17] Add group privacy command/structures --- PluralKit.Bot/Commands/CommandTree.cs | 7 ++ PluralKit.Bot/Commands/Groups.cs | 79 +++++++++++++++++-- .../Commands/Privacy/ContextPrivacyExt.cs | 9 +++ PluralKit.Bot/Services/EmbedService.cs | 2 +- PluralKit.Core/Models/ModelExtensions.cs | 6 ++ .../Models/Privacy/GroupPrivacySubject.cs | 66 ++++++++++++++++ 6 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 PluralKit.Core/Models/Privacy/GroupPrivacySubject.cs diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index f219ea18..44edbb8a 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -53,6 +53,7 @@ namespace PluralKit.Bot 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 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"); @@ -342,6 +343,12 @@ 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("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.HasNext()) await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, target)); else diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index d381dcdb..7d50efaf 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -107,7 +108,11 @@ namespace PluralKit.Bot // TODO: integrate with the normal "search" system await using var conn = await _db.Obtain(); - var groups = (await conn.QueryGroupsInSystem(system.Id)).ToList(); + var pctx = LookupContext.ByNonOwner; + if (ctx.MatchFlag("a", "all") && system.Id == ctx.System.Id) + pctx = LookupContext.ByOwner; + + var groups = (await conn.QueryGroupsInSystem(system.Id)).Where(g => g.Visibility.CanAccess(pctx)).ToList(); if (groups.Count == 0) { if (system.Id == ctx.System?.Id) @@ -139,6 +144,7 @@ namespace PluralKit.Bot 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; @@ -146,7 +152,7 @@ namespace PluralKit.Bot nameField = $"{nameField} ({system.Name})"; var eb = new DiscordEmbedBuilder() - .WithAuthor(nameField) + .WithAuthor(nameField, iconUrl: DiscordUtils.WorkaroundForUrlBug(target.IconFor(pctx))) .WithFooter($"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}"); if (memberCount == 0) @@ -154,8 +160,11 @@ namespace PluralKit.Bot else eb.AddField($"Members ({memberCount})", $"(see `pk;group {target.Hid} list`)", true); - if (target.Description != null) - eb.AddField("Description", target.Description); + 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()); } @@ -224,6 +233,66 @@ namespace PluralKit.Bot 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("Visibility", target.Visibility.Explanation()) + .WithDescription("To edit privacy settings, use the command:\n`pk;group privacy `\n\n- `subject` is one of `description`, `icon`, `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 member 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 member 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.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.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.", + + _ => 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()); + } private static async Task GetGroupSystem(Context ctx, PKGroup target, IPKConnection conn) { 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/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 50de24e2..ac83eab3 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -109,7 +109,7 @@ namespace PluralKit.Bot { var guildDisplayName = guildSettings?.DisplayName; var avatar = guildSettings?.AvatarUrl ?? member.AvatarFor(ctx); - var groups = (await conn.QueryMemberGroups(member.Id)).ToList(); + 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} ``")); diff --git a/PluralKit.Core/Models/ModelExtensions.cs b/PluralKit.Core/Models/ModelExtensions.cs index 6092fa24..1c434c70 100644 --- a/PluralKit.Core/Models/ModelExtensions.cs +++ b/PluralKit.Core/Models/ModelExtensions.cs @@ -27,5 +27,11 @@ namespace PluralKit.Core public static int MessageCountFor(this PKMember member, LookupContext ctx) => member.MetadataPrivacy.Get(ctx, member.MessageCount); + + 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/Privacy/GroupPrivacySubject.cs b/PluralKit.Core/Models/Privacy/GroupPrivacySubject.cs new file mode 100644 index 00000000..63487c16 --- /dev/null +++ b/PluralKit.Core/Models/Privacy/GroupPrivacySubject.cs @@ -0,0 +1,66 @@ +using System; + +namespace PluralKit.Core +{ + public enum GroupPrivacySubject + { + Description, + Icon, + 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.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": + case "list": + subject = GroupPrivacySubject.Visibility; + break; + default: + subject = default; + return false; + } + + return true; + } + } +} \ No newline at end of file From f504e16543abd74e98758b6d75005835e3810d5e Mon Sep 17 00:00:00 2001 From: Ske Date: Sat, 18 Jul 2020 16:49:00 +0200 Subject: [PATCH 12/17] Restructure group created info --- PluralKit.Bot/Commands/Groups.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 7d50efaf..53646939 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -38,7 +38,14 @@ namespace PluralKit.Bot throw new PKError($"System has reached the maximum number of groups ({Limits.MaxGroupCount}). Please delete unused groups first in order to create new ones."); var newGroup = await conn.CreateGroup(ctx.System.Id, groupName); - await ctx.Reply($"{Emojis.Success} Group \"**{groupName}**\" (`{newGroup.Hid}`) registered!\nYou can now start adding members to the group like this:\n> **pk;group `{newGroup.Hid}` add `member1` `member2...`**"); + + 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 **{newGroup.Hid}**") + .AddField("Add members to the group", $"> pk;group **{newGroup.Hid}** add **MemberName**\n> pk;group **{newGroup.Hid}** add **Member1** **Member2** **Member3** (and so on...)") + .AddField("Set the description", $"> pk;group **{newGroup.Hid}** description **This is my new group, and here is the description!**") + .AddField("Set the group icon", $"> pk;group **{newGroup.Hid}** icon\n*(with an image attached)*"); + await ctx.Reply($"{Emojis.Success} Group created!", eb.Build()); } public async Task RenameGroup(Context ctx, PKGroup target) From cedb050b8aefb0be2baa6ddad073ce4b868e2d86 Mon Sep 17 00:00:00 2001 From: Ske Date: Sat, 8 Aug 2020 14:56:34 +0200 Subject: [PATCH 13/17] Add group delete command --- PluralKit.Bot/Commands/CommandTree.cs | 3 ++ PluralKit.Bot/Commands/Groups.cs | 49 ++++++++++++++------ PluralKit.Core/Models/Patch/ModelPatchExt.cs | 3 ++ 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index 44edbb8a..d7261b17 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -54,6 +54,7 @@ namespace PluralKit.Bot 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 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"); @@ -349,6 +350,8 @@ namespace PluralKit.Bot 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.HasNext()) await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, target)); else diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 53646939..7814a6a8 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -2,14 +2,13 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Dapper; using DSharpPlus.Entities; -using NodaTime; - using PluralKit.Core; namespace PluralKit.Bot @@ -29,7 +28,7 @@ namespace PluralKit.Bot 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.MaxMemberNameLength} characters)."); + throw new PKError($"Group name too long ({groupName.Length}/{Limits.MaxGroupNameLength} characters)."); await using var conn = await _db.Obtain(); @@ -41,10 +40,10 @@ namespace PluralKit.Bot 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 **{newGroup.Hid}**") - .AddField("Add members to the group", $"> pk;group **{newGroup.Hid}** add **MemberName**\n> pk;group **{newGroup.Hid}** add **Member1** **Member2** **Member3** (and so on...)") - .AddField("Set the description", $"> pk;group **{newGroup.Hid}** description **This is my new group, and here is the description!**") - .AddField("Set the group icon", $"> pk;group **{newGroup.Hid}** icon\n*(with an image attached)*"); + .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()); } @@ -76,7 +75,7 @@ namespace PluralKit.Bot { 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 {target.Hid} description `."); + 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")) @@ -85,8 +84,8 @@ namespace PluralKit.Bot await ctx.Reply(embed: new DiscordEmbedBuilder() .WithTitle("Group description") .WithDescription(target.Description) - .AddField("\u200B", $"To print the description with formatting, type `pk;group {target.Hid} description -raw`." - + (ctx.System?.Id == target.System ? $" To clear it, type `pk;group {target.Hid} description -clear`." : "")) + .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 @@ -163,9 +162,9 @@ namespace PluralKit.Bot .WithFooter($"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}"); if (memberCount == 0) - eb.AddField("Members (0)", $"Add one with `pk;group {target.Hid} add `!", true); + eb.AddField("Members (0)", $"Add one with `pk;group {GroupReference(target)} add `!", true); else - eb.AddField($"Members ({memberCount})", $"(see `pk;group {target.Hid} list`)", true); + eb.AddField($"Members ({memberCount})", $"(see `pk;group {GroupReference(target)} list`)", true); if (target.DescriptionFor(pctx) is {} desc) eb.AddField("Description", desc); @@ -252,7 +251,7 @@ namespace PluralKit.Bot .AddField("Description", target.DescriptionPrivacy.Explanation()) .AddField("Icon", target.IconPrivacy.Explanation()) .AddField("Visibility", target.Visibility.Explanation()) - .WithDescription("To edit privacy settings, use the command:\n`pk;group privacy `\n\n- `subject` is one of `description`, `icon`, `visibility`, or `all`\n- `level` is either `public` or `private`.") + .WithDescription($"To edit privacy settings, use the command:\n`pk;group **{GroupReference(target)}** privacy `\n\n- `subject` is one of `description`, `icon`, `visibility`, or `all`\n- `level` is either `public` or `private`.") .Build()); return; } @@ -262,9 +261,9 @@ namespace PluralKit.Bot 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 member card."); + 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 member card."); + 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) @@ -301,6 +300,19 @@ namespace PluralKit.Bot 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; @@ -308,5 +320,12 @@ namespace PluralKit.Bot 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.Core/Models/Patch/ModelPatchExt.cs b/PluralKit.Core/Models/Patch/ModelPatchExt.cs index b7074d92..f18b0ba7 100644 --- a/PluralKit.Core/Models/Patch/ModelPatchExt.cs +++ b/PluralKit.Core/Models/Patch/ModelPatchExt.cs @@ -75,6 +75,9 @@ namespace PluralKit.Core .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) { From d702d8c9b6117d4f1a7542c0240e3b35068a09e6 Mon Sep 17 00:00:00 2001 From: Ske Date: Sat, 8 Aug 2020 15:09:42 +0200 Subject: [PATCH 14/17] Add group icon command --- PluralKit.Bot/Commands/CommandTree.cs | 3 ++ PluralKit.Bot/Commands/Groups.cs | 62 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index d7261b17..4aeccb7f 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -55,6 +55,7 @@ namespace PluralKit.Bot 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"); @@ -352,6 +353,8 @@ namespace PluralKit.Bot 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 diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 7814a6a8..1f336ce2 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -103,6 +103,68 @@ namespace PluralKit.Bot } } + 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) From 9e251352c7c624bd5d337889b2ec78b3056ea906 Mon Sep 17 00:00:00 2001 From: Ske Date: Sun, 16 Aug 2020 12:10:54 +0200 Subject: [PATCH 15/17] Various fixes and improvements --- .../CommandSystem/ContextChecksExt.cs | 2 +- .../ContextEntityArgumentsExt.cs | 2 +- PluralKit.Bot/Commands/CommandTree.cs | 22 ++++- PluralKit.Bot/Commands/Groups.cs | 93 ++++++++++++++----- PluralKit.Bot/Errors.cs | 2 +- PluralKit.Bot/Services/EmbedService.cs | 16 +++- PluralKit.Bot/Utils/DiscordUtils.cs | 2 +- PluralKit.Core/Database/Migrations/9.sql | 3 +- PluralKit.Core/Models/ModelQueryExt.cs | 4 +- PluralKit.Core/Utils/Limits.cs | 2 +- docs/content/user-guide.md | 56 +++++++++++ 11 files changed, 168 insertions(+), 36 deletions(-) diff --git a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs index 86d62f59..5ae896bb 100644 --- a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs @@ -28,7 +28,7 @@ namespace PluralKit.Bot public static Context CheckOwnGroup(this Context ctx, PKGroup group) { if (group.System != ctx.System?.Id) - throw Errors.NotOwnMemberError; + throw Errors.NotOwnGroupError; return ctx; } diff --git a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs index 89952702..4926efe7 100644 --- a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs @@ -103,7 +103,7 @@ namespace PluralKit.Bot var input = ctx.PeekArgument(); await using var conn = await ctx.Database.Obtain(); - if (await conn.QueryGroupByName(input) is {} byName) + if (ctx.System != null && await conn.QueryGroupByName(ctx.System.Id, input) is {} byName) return byName; if (await conn.QueryGroupByHid(input) is {} byHid) return byHid; diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index 4aeccb7f..38f63c05 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -90,6 +90,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}; @@ -227,6 +239,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 @@ -262,6 +276,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 @@ -332,6 +348,8 @@ namespace PluralKit.Bot 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 @@ -358,10 +376,10 @@ namespace PluralKit.Bot else if (!ctx.HasNext()) await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, target)); else - await PrintCommandNotFoundError(ctx, GroupInfo, GroupRename, GroupDesc); + await PrintCommandNotFoundError(ctx, GroupCommandsTargeted); } else if (!ctx.HasNext()) - await PrintCommandNotFoundError(ctx, GroupInfo, GroupList, GroupNew, GroupRename, GroupDesc); + await PrintCommandNotFoundError(ctx, GroupCommands); else await ctx.Reply($"{Emojis.Error} {ctx.CreateGroupNotFoundError(ctx.PopArgument())}"); } diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 1f336ce2..2c95a7c8 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -9,6 +9,8 @@ using Dapper; using DSharpPlus.Entities; +using Humanizer; + using PluralKit.Core; namespace PluralKit.Bot @@ -26,15 +28,25 @@ namespace PluralKit.Bot { 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(); - - var existingGroupCount = await conn.QuerySingleAsync("select count(*) from groups where system = @System", ctx.System.Id); + + // 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); @@ -51,11 +63,21 @@ namespace PluralKit.Bot { 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}**\"."); @@ -173,14 +195,26 @@ namespace PluralKit.Bot system = ctx.System; } + // should this be split off to a separate permission? + ctx.CheckSystemPrivacy(system, system.MemberListPrivacy); + // TODO: integrate with the normal "search" system await using var conn = await _db.Obtain(); var pctx = LookupContext.ByNonOwner; - if (ctx.MatchFlag("a", "all") && system.Id == ctx.System.Id) - pctx = LookupContext.ByOwner; + 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.QueryGroupsInSystem(system.Id)).Where(g => g.Visibility.CanAccess(pctx)).ToList(); + var groups = (await conn.QueryGroupsInSystem(system.Id)) + .Where(g => g.Visibility.CanAccess(pctx)) + .ToList(); + if (groups.Count == 0) { if (system.Id == ctx.System?.Id) @@ -195,14 +229,8 @@ namespace PluralKit.Bot Task Renderer(DiscordEmbedBuilder eb, IEnumerable page) { - var sb = new StringBuilder(); - foreach (var g in page) - { - sb.Append($"[`{g.Hid}`] **{g.Name}**\n"); - } - - eb.WithDescription(sb.ToString()); - eb.WithFooter($"{groups.Count} total"); + eb.WithSimpleLineContent(page.Select(g => $"[`{g.Hid}`] **{g.Name}**")); + eb.WithFooter($"{groups.Count} total."); return Task.CompletedTask; } } @@ -244,23 +272,46 @@ namespace PluralKit.Bot 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) + .ToHashSet(); + if (op == AddRemoveOperation.Add) { - await conn.AddMembersToGroup(target.Id, members.Select(m => m.Id)); - await ctx.Reply($"{Emojis.Success} Members added to group."); + var membersNotInGroup = members + .Where(m => !existingMembersInGroup.Contains(m.Id)) + .Select(m => m.Id) + .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) { - await conn.RemoveMembersFromGroup(target.Id, members.Select(m => m.Id)); - await ctx.Reply($"{Emojis.Success} Members removed from group."); + var membersInGroup = members + .Where(m => existingMembersInGroup.Contains(m.Id)) + .Select(m => m.Id) + .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, targetSystem.MemberListPrivacy); + ctx.CheckSystemPrivacy(targetSystem, target.Visibility); var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(target.System)); opts.GroupFilter = target.Id; @@ -313,7 +364,7 @@ namespace PluralKit.Bot .AddField("Description", target.DescriptionPrivacy.Explanation()) .AddField("Icon", target.IconPrivacy.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`, `visibility`, or `all`\n- `level` is either `public` or `private`.") + .WithDescription($"To edit privacy settings, use the command:\n> pk;group **{GroupReference(target)}** privacy **** ****\n\n- `subject` is one of `description`, `icon`, `visibility`, or `all`\n- `level` is either `public` or `private`.") .Build()); return; } @@ -385,7 +436,7 @@ namespace PluralKit.Bot private static string GroupReference(PKGroup group) { - if (Regex.IsMatch(group.Name, "[A-Za-z0-9\\-_]+")) + if (Regex.IsMatch(group.Name, "^[A-Za-z0-9\\-_]+$")) return group.Name; return group.Hid; } diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 58c673a6..5f03f8f7 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -106,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/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index ac83eab3..a813dd52 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -128,10 +128,7 @@ namespace PluralKit.Bot { else description += "*(this member has a server-specific avatar set)*\n"; if (description != "") eb.WithDescription(description); - - if (groups.Count > 0) - eb.AddField($"Groups ({groups.Count})", string.Join("\n", groups.Select(g => $"[`{g.Hid}`] **{g.Name}**")).Truncate(1000)); - + if (avatar != null) eb.WithThumbnail(avatar); if (!member.DisplayName.EmptyOrNull() && member.NamePrivacy.CanAccess(ctx)) eb.AddField("Display Name", member.DisplayName.Truncate(1024), true); @@ -145,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.Name)) + : string.Join("\n", groups.Select(g => $"[`{g.Hid}`] **{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.Bot/Utils/DiscordUtils.cs b/PluralKit.Bot/Utils/DiscordUtils.cs index cfc911ca..be68dc02 100644 --- a/PluralKit.Bot/Utils/DiscordUtils.cs +++ b/PluralKit.Bot/Utils/DiscordUtils.cs @@ -212,7 +212,7 @@ namespace PluralKit.Bot public static string EscapeBacktickPair(this string input){ Regex doubleBacktick = new Regex(@"``", RegexOptions.Multiline); //Run twice to catch any pairs that are created from the first pass, pairs shouldn't be created in the second as they are created from odd numbers of backticks, even numbers are all caught on the first pass - if(input != null) return doubleBacktick.Replace(doubleBacktick.Replace(input, @"`‌ `"),@"`‌`"); + if(input != null) return doubleBacktick.Replace(doubleBacktick.Replace(input, @"`‌ `"),@"`‌ `"); else return input; } diff --git a/PluralKit.Core/Database/Migrations/9.sql b/PluralKit.Core/Database/Migrations/9.sql index 60444326..8132ac3f 100644 --- a/PluralKit.Core/Database/Migrations/9.sql +++ b/PluralKit.Core/Database/Migrations/9.sql @@ -20,7 +20,8 @@ create table groups ( 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 + member_id int not null references members(id) on delete cascade, + primary key (group_id, member_id) ); update info set schema_version = 9; diff --git a/PluralKit.Core/Models/ModelQueryExt.cs b/PluralKit.Core/Models/ModelQueryExt.cs index 8eaa3da2..228992eb 100644 --- a/PluralKit.Core/Models/ModelQueryExt.cs +++ b/PluralKit.Core/Models/ModelQueryExt.cs @@ -29,8 +29,8 @@ 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, string name) => - conn.QueryFirstOrDefaultAsync("select * from groups where lower(name) = lower(@name)", new {name = name}); + 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()}); diff --git a/PluralKit.Core/Utils/Limits.cs b/PluralKit.Core/Utils/Limits.cs index 769acb3a..0297ae33 100644 --- a/PluralKit.Core/Utils/Limits.cs +++ b/PluralKit.Core/Utils/Limits.cs @@ -7,7 +7,7 @@ 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 = 50; // TODO: up to 100+? + 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; diff --git a/docs/content/user-guide.md b/docs/content/user-guide.md index e1db6995..e633b294 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. From 1bb5d203df25cdc0404205da2e385ead94799667 Mon Sep 17 00:00:00 2001 From: Ske Date: Thu, 20 Aug 2020 21:43:17 +0200 Subject: [PATCH 16/17] Various additional tweaks/additions to groups --- PluralKit.Bot/Commands/CommandTree.cs | 3 + PluralKit.Bot/Commands/Groups.cs | 90 +++++++++++++++---- PluralKit.Bot/Commands/SystemEdit.cs | 6 +- PluralKit.Bot/Services/EmbedService.cs | 4 +- PluralKit.Core/Database/Migrations/9.sql | 4 + PluralKit.Core/Models/PKGroup.cs | 2 + PluralKit.Core/Models/PKSystem.cs | 1 + PluralKit.Core/Models/Patch/GroupPatch.cs | 4 + PluralKit.Core/Models/Patch/SystemPatch.cs | 2 + .../Models/Privacy/GroupPrivacySubject.cs | 8 +- PluralKit.Core/Models/Privacy/PrivacyExt.cs | 4 +- .../Models/Privacy/SystemPrivacySubject.cs | 6 ++ 12 files changed, 109 insertions(+), 25 deletions(-) diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index 38f63c05..99c83b7a 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -50,6 +50,7 @@ namespace PluralKit.Bot 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"); @@ -355,6 +356,8 @@ namespace PluralKit.Bot // 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")) diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 2c95a7c8..9bb17cfb 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -80,7 +80,43 @@ namespace PluralKit.Bot await conn.UpdateGroup(target.Id, new GroupPatch {Name = newName}); - await ctx.Reply($"{Emojis.Success} Group name changed from \"**{target.Name}**\" to \"**{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) @@ -195,8 +231,7 @@ namespace PluralKit.Bot system = ctx.System; } - // should this be split off to a separate permission? - ctx.CheckSystemPrivacy(system, system.MemberListPrivacy); + ctx.CheckSystemPrivacy(system, system.GroupListPrivacy); // TODO: integrate with the normal "search" system await using var conn = await _db.Obtain(); @@ -209,8 +244,7 @@ namespace PluralKit.Bot else throw new PKError("You do not have permission to access this information."); } - - + var groups = (await conn.QueryGroupsInSystem(system.Id)) .Where(g => g.Visibility.CanAccess(pctx)) .ToList(); @@ -218,9 +252,10 @@ namespace PluralKit.Bot 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 `."); + 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."); + await ctx.Reply("This system has no groups."); + return; } @@ -229,7 +264,13 @@ namespace PluralKit.Bot Task Renderer(DiscordEmbedBuilder eb, IEnumerable page) { - eb.WithSimpleLineContent(page.Select(g => $"[`{g.Hid}`] **{g.Name}**")); + eb.WithSimpleLineContent(page.Select(g => + { + if (g.DisplayName != null) + return $"[`{g.Hid}`] **{g.Name}** ({g.DisplayName})"; + else + return $"[`{g.Hid}`] **{g.Name}**"; + })); eb.WithFooter($"{groups.Count} total."); return Task.CompletedTask; } @@ -251,10 +292,17 @@ namespace PluralKit.Bot .WithAuthor(nameField, iconUrl: DiscordUtils.WorkaroundForUrlBug(target.IconFor(pctx))) .WithFooter($"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}"); - if (memberCount == 0) - 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.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); @@ -275,14 +323,15 @@ namespace PluralKit.Bot var existingMembersInGroup = (await conn.QueryMemberList(target.System, new DatabaseViewsExt.MemberListQueryOptions {GroupFilter = target.Id})) - .Select(m => m.Id) + .Select(m => m.Id.Value) .ToHashSet(); if (op == AddRemoveOperation.Add) { var membersNotInGroup = members - .Where(m => !existingMembersInGroup.Contains(m.Id)) + .Where(m => !existingMembersInGroup.Contains(m.Id.Value)) .Select(m => m.Id) + .Distinct() .ToList(); await conn.AddMembersToGroup(target.Id, membersNotInGroup); @@ -294,8 +343,9 @@ namespace PluralKit.Bot else if (op == AddRemoveOperation.Remove) { var membersInGroup = members - .Where(m => existingMembersInGroup.Contains(m.Id)) + .Where(m => existingMembersInGroup.Contains(m.Id.Value)) .Select(m => m.Id) + .Distinct() .ToList(); await conn.RemoveMembersFromGroup(target.Id, membersInGroup); @@ -311,12 +361,12 @@ namespace PluralKit.Bot await using var conn = await _db.Obtain(); var targetSystem = await GetGroupSystem(ctx, target, conn); - ctx.CheckSystemPrivacy(targetSystem, target.Visibility); + ctx.CheckSystemPrivacy(targetSystem, target.ListPrivacy); var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(target.System)); opts.GroupFilter = target.Id; - var title = new StringBuilder($"Members of {target.Name} (`{target.Hid}`) in "); + var title = new StringBuilder($"Members of {target.DisplayName ?? target.Name} (`{target.Hid}`) in "); if (targetSystem.Name != null) title.Append($"{targetSystem.Name} (`{targetSystem.Hid}`)"); else @@ -363,8 +413,9 @@ namespace PluralKit.Bot .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`, `visibility`, or `all`\n- `level` is either `public` or `private`.") + .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; } @@ -387,6 +438,7 @@ namespace PluralKit.Bot { GroupPrivacySubject.Description => "description privacy", GroupPrivacySubject.Icon => "icon privacy", + GroupPrivacySubject.List => "member list", GroupPrivacySubject.Visibility => "visibility", _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") }; @@ -396,10 +448,12 @@ namespace PluralKit.Bot (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})") }; 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/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index a813dd52..5985a3e5 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -147,8 +147,8 @@ namespace PluralKit.Bot { { // More than 5 groups show in "compact" format without ID var content = groups.Count > 5 - ? string.Join(", ", groups.Select(g => g.Name)) - : string.Join("\n", groups.Select(g => $"[`{g.Hid}`] **{g.Name}**")); + ? 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)); } diff --git a/PluralKit.Core/Database/Migrations/9.sql b/PluralKit.Core/Database/Migrations/9.sql index 8132ac3f..d7fb6ccb 100644 --- a/PluralKit.Core/Database/Migrations/9.sql +++ b/PluralKit.Core/Database/Migrations/9.sql @@ -7,12 +7,14 @@ create table groups ( 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') @@ -24,4 +26,6 @@ create table group_members ( 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 systems.member_list_privacy; + update info set schema_version = 9; diff --git a/PluralKit.Core/Models/PKGroup.cs b/PluralKit.Core/Models/PKGroup.cs index e553b902..b5bf26f6 100644 --- a/PluralKit.Core/Models/PKGroup.cs +++ b/PluralKit.Core/Models/PKGroup.cs @@ -10,11 +10,13 @@ namespace PluralKit.Core public SystemId System { get; } public string Name { get; } = null!; + public string? DisplayName { get; } public string? Description { get; } public string? Icon { get; } public PrivacyLevel DescriptionPrivacy { get; } public PrivacyLevel IconPrivacy { get; } + public PrivacyLevel ListPrivacy { get; } public PrivacyLevel Visibility { get; } public Instant Created { get; } diff --git a/PluralKit.Core/Models/PKSystem.cs b/PluralKit.Core/Models/PKSystem.cs index aff336af..562261f0 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 index 02281d8c..2f154c90 100644 --- a/PluralKit.Core/Models/Patch/GroupPatch.cs +++ b/PluralKit.Core/Models/Patch/GroupPatch.cs @@ -4,19 +4,23 @@ 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/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 index 63487c16..90691d48 100644 --- a/PluralKit.Core/Models/Privacy/GroupPrivacySubject.cs +++ b/PluralKit.Core/Models/Privacy/GroupPrivacySubject.cs @@ -6,6 +6,7 @@ namespace PluralKit.Core { Description, Icon, + List, Visibility } @@ -18,6 +19,7 @@ namespace PluralKit.Core { 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}") }; @@ -52,9 +54,13 @@ namespace PluralKit.Core case "hidden": case "shown": case "visible": - case "list": subject = GroupPrivacySubject.Visibility; break; + case "list": + case "listing": + case "members": + subject = GroupPrivacySubject.List; + break; default: subject = default; return false; diff --git a/PluralKit.Core/Models/Privacy/PrivacyExt.cs b/PluralKit.Core/Models/Privacy/PrivacyExt.cs index 548f963e..3ce62859 100644 --- a/PluralKit.Core/Models/Privacy/PrivacyExt.cs +++ b/PluralKit.Core/Models/Privacy/PrivacyExt.cs @@ -16,8 +16,8 @@ namespace PluralKit.Core public static string Explanation(this PrivacyLevel level) => level switch { - PrivacyLevel.Private => "**Private** (visible only when queried by you)", - PrivacyLevel.Public => "**Public** (visible to everyone)", + PrivacyLevel.Private => "Private *(visible only when queried by you)*", + PrivacyLevel.Public => "Public *(visible to everyone)*", _ => throw new ArgumentOutOfRangeException(nameof(level), level, null) }; 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; From 10c01da39b5cd0c67162030365c085d89514f6e8 Mon Sep 17 00:00:00 2001 From: Ske Date: Fri, 21 Aug 2020 17:08:49 +0200 Subject: [PATCH 17/17] Couple more slight tweaks :) --- PluralKit.Bot/Commands/Groups.cs | 8 +++---- PluralKit.Core/Database/Migrations/9.sql | 3 ++- .../Database/Views/DatabaseViewsExt.cs | 3 +++ PluralKit.Core/Database/Views/ListedGroup.cs | 7 ++++++ PluralKit.Core/Database/Views/views.sql | 8 ++++++- PluralKit.Core/Database/clean.sql | 1 + PluralKit.Core/Models/ModelQueryExt.cs | 5 +--- PluralKit.Core/Models/PKGroup.cs | 24 +++++++++---------- 8 files changed, 37 insertions(+), 22 deletions(-) create mode 100644 PluralKit.Core/Database/Views/ListedGroup.cs diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 9bb17cfb..e4471cba 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -245,7 +245,7 @@ namespace PluralKit.Bot throw new PKError("You do not have permission to access this information."); } - var groups = (await conn.QueryGroupsInSystem(system.Id)) + var groups = (await conn.QueryGroupList(system.Id)) .Where(g => g.Visibility.CanAccess(pctx)) .ToList(); @@ -262,14 +262,14 @@ namespace PluralKit.Bot var title = system.Name != null ? $"Groups of {system.Name} (`{system.Hid}`)" : $"Groups of `{system.Hid}`"; await ctx.Paginate(groups.ToAsyncEnumerable(), groups.Count, 25, title, Renderer); - Task Renderer(DiscordEmbedBuilder eb, IEnumerable page) + Task Renderer(DiscordEmbedBuilder eb, IEnumerable page) { eb.WithSimpleLineContent(page.Select(g => { if (g.DisplayName != null) - return $"[`{g.Hid}`] **{g.Name}** ({g.DisplayName})"; + return $"[`{g.Hid}`] **{g.Name}** ({g.DisplayName}) ({"member".ToQuantity(g.MemberCount)})"; else - return $"[`{g.Hid}`] **{g.Name}**"; + return $"[`{g.Hid}`] **{g.Name}** ({"member".ToQuantity(g.MemberCount)})"; })); eb.WithFooter($"{groups.Count} total."); return Task.CompletedTask; diff --git a/PluralKit.Core/Database/Migrations/9.sql b/PluralKit.Core/Database/Migrations/9.sql index d7fb6ccb..bf409529 100644 --- a/PluralKit.Core/Database/Migrations/9.sql +++ b/PluralKit.Core/Database/Migrations/9.sql @@ -26,6 +26,7 @@ create table group_members ( 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 systems.member_list_privacy; +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 c399a28d..0171900b 100644 --- a/PluralKit.Core/Database/Views/DatabaseViewsExt.cs +++ b/PluralKit.Core/Database/Views/DatabaseViewsExt.cs @@ -11,6 +11,9 @@ 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) { 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 f950c6ec..3338f4bf 100644 --- a/PluralKit.Core/Database/clean.sql +++ b/PluralKit.Core/Database/clean.sql @@ -5,6 +5,7 @@ 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; diff --git a/PluralKit.Core/Models/ModelQueryExt.cs b/PluralKit.Core/Models/ModelQueryExt.cs index 228992eb..45ee566f 100644 --- a/PluralKit.Core/Models/ModelQueryExt.cs +++ b/PluralKit.Core/Models/ModelQueryExt.cs @@ -34,10 +34,7 @@ namespace PluralKit.Core public static Task QueryGroupByHid(this IPKConnection conn, string hid) => conn.QueryFirstOrDefaultAsync("select * from groups where hid = @hid", new {hid = hid.ToLowerInvariant()}); - - public static Task> QueryGroupsInSystem(this IPKConnection conn, SystemId system) => - conn.QueryAsync("select * from groups where system = @System", new {System = system}); - + public static Task QueryGroupMemberCount(this IPKConnection conn, GroupId id, PrivacyLevel? privacyFilter = null) { diff --git a/PluralKit.Core/Models/PKGroup.cs b/PluralKit.Core/Models/PKGroup.cs index b5bf26f6..d373c614 100644 --- a/PluralKit.Core/Models/PKGroup.cs +++ b/PluralKit.Core/Models/PKGroup.cs @@ -5,20 +5,20 @@ namespace PluralKit.Core { public class PKGroup { - public GroupId Id { get; } - public string Hid { get; } = null!; - public SystemId System { get; } + public GroupId Id { get; private set; } + public string Hid { get; private set; } = null!; + public SystemId System { get; private set; } - public string Name { get; } = null!; - public string? DisplayName { get; } - public string? Description { get; } - public string? Icon { get; } + 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; } - public PrivacyLevel IconPrivacy { get; } - public PrivacyLevel ListPrivacy { get; } - public PrivacyLevel Visibility { get; } + 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; } + public Instant Created { get; private set; } } } \ No newline at end of file