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;