diff --git a/PluralKit/Bot.cs b/PluralKit/Bot/Bot.cs similarity index 98% rename from PluralKit/Bot.cs rename to PluralKit/Bot/Bot.cs index c004900c..2a43b4d9 100644 --- a/PluralKit/Bot.cs +++ b/PluralKit/Bot/Bot.cs @@ -15,7 +15,7 @@ using Npgsql.TypeHandling; using Npgsql.TypeMapping; using NpgsqlTypes; -namespace PluralKit +namespace PluralKit.Bot { class Initialize { @@ -58,6 +58,7 @@ namespace PluralKit .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() @@ -67,7 +68,6 @@ namespace PluralKit .BuildServiceProvider(); } - class Bot { private IServiceProvider _services; diff --git a/PluralKit/Commands/SystemCommands.cs b/PluralKit/Bot/Commands/SystemCommands.cs similarity index 80% rename from PluralKit/Commands/SystemCommands.cs rename to PluralKit/Bot/Commands/SystemCommands.cs index 5bafc086..7ef9de53 100644 --- a/PluralKit/Commands/SystemCommands.cs +++ b/PluralKit/Bot/Commands/SystemCommands.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using Dapper; using Discord.Commands; -namespace PluralKit.Commands +namespace PluralKit.Bot.Commands { [Group("system")] public class SystemCommands : ContextParameterModuleBase @@ -11,24 +11,36 @@ namespace PluralKit.Commands public override string Prefix => "system"; public SystemStore Systems {get; set;} public MemberStore Members {get; set;} + public EmbedService EmbedService {get; set;} private RuntimeResult NO_SYSTEM_ERROR => PKResult.Error($"You do not have a system registered with PluralKit. To create one, type `pk;system new`. If you already have a system registered on another account, type `pk;link {Context.User.Mention}` from that account to link it here."); private RuntimeResult OTHER_SYSTEM_CONTEXT_ERROR => PKResult.Error("You can only run this command on your own system."); + [Command] + public async Task Query(PKSystem system = null) { + if (system == null) system = Context.SenderSystem; + if (system == null) return NO_SYSTEM_ERROR; + + await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateEmbed(system)); + return PKResult.Success(); + } + [Command("new")] public async Task New([Remainder] string systemName = null) { - if (Context.ContextEntity != null) return OTHER_SYSTEM_CONTEXT_ERROR; + if (ContextEntity != null) return OTHER_SYSTEM_CONTEXT_ERROR; if (Context.SenderSystem != null) return PKResult.Error("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."); var system = await Systems.Create(systemName); + await Systems.Link(system, Context.User.Id); + await ReplyAsync("Your system has been created. Type `pk;system` to view it, and type `pk;help` for more information about commands you can use now."); return PKResult.Success(); } [Command("name")] public async Task Name([Remainder] string newSystemName = null) { - if (Context.ContextEntity != null) return OTHER_SYSTEM_CONTEXT_ERROR; + if (ContextEntity != null) return OTHER_SYSTEM_CONTEXT_ERROR; if (Context.SenderSystem == null) return NO_SYSTEM_ERROR; if (newSystemName != null && newSystemName.Length > 250) return PKResult.Error($"Your chosen system name is too long. ({newSystemName.Length} > 250 characters)"); @@ -39,7 +51,7 @@ namespace PluralKit.Commands [Command("description")] public async Task Description([Remainder] string newDescription = null) { - if (Context.ContextEntity != null) return OTHER_SYSTEM_CONTEXT_ERROR; + if (ContextEntity != null) return OTHER_SYSTEM_CONTEXT_ERROR; if (Context.SenderSystem == null) return NO_SYSTEM_ERROR; if (newDescription != null && newDescription.Length > 1000) return PKResult.Error($"Your chosen description is too long. ({newDescription.Length} > 250 characters)"); @@ -50,7 +62,7 @@ namespace PluralKit.Commands [Command("tag")] public async Task Tag([Remainder] string newTag = null) { - if (Context.ContextEntity != null) return OTHER_SYSTEM_CONTEXT_ERROR; + if (ContextEntity != null) return OTHER_SYSTEM_CONTEXT_ERROR; if (Context.SenderSystem == null) return NO_SYSTEM_ERROR; Context.SenderSystem.Tag = newTag; diff --git a/PluralKit/Bot/Services/EmbedService.cs b/PluralKit/Bot/Services/EmbedService.cs new file mode 100644 index 00000000..8ec77c76 --- /dev/null +++ b/PluralKit/Bot/Services/EmbedService.cs @@ -0,0 +1,35 @@ +using System.Linq; +using System.Threading.Tasks; +using Discord; + +namespace PluralKit.Bot { + public class EmbedService { + private SystemStore _systems; + private IDiscordClient _client; + + public EmbedService(SystemStore systems, IDiscordClient client) + { + this._systems = systems; + this._client = client; + } + + public async Task CreateEmbed(PKSystem system) { + var accounts = await _systems.GetLinkedAccountIds(system); + + // Fetch/render info for all accounts simultaneously + var users = await Task.WhenAll(accounts.Select(async uid => (await _client.GetUserAsync(uid)).NameAndMention() ?? $"(deleted account {uid})")); + + var eb = new EmbedBuilder() + .WithColor(Color.Blue) + .WithTitle(system.Name ?? null) + .WithDescription(system.Description?.Truncate(1024)) + .WithThumbnailUrl(system.AvatarUrl ?? null) + .WithFooter($"System ID: {system.Hid}"); + + eb.AddField("Linked accounts", string.Join(", ", users)); + eb.AddField("Members", $"(see `pk;system {system.Id} list` or `pk;system {system.Hid} list full`)"); + // TODO: fronter + return eb.Build(); + } + } +} \ No newline at end of file diff --git a/PluralKit/Services/LogChannelService.cs b/PluralKit/Bot/Services/LogChannelService.cs similarity index 98% rename from PluralKit/Services/LogChannelService.cs rename to PluralKit/Bot/Services/LogChannelService.cs index 3eee54d4..f10f5ee4 100644 --- a/PluralKit/Services/LogChannelService.cs +++ b/PluralKit/Bot/Services/LogChannelService.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using Dapper; using Discord; -namespace PluralKit { +namespace PluralKit.Bot { class ServerDefinition { public ulong Id; public ulong LogChannel; diff --git a/PluralKit/Services/ProxyService.cs b/PluralKit/Bot/Services/ProxyService.cs similarity index 99% rename from PluralKit/Services/ProxyService.cs rename to PluralKit/Bot/Services/ProxyService.cs index c8f2bf42..d57ee524 100644 --- a/PluralKit/Services/ProxyService.cs +++ b/PluralKit/Bot/Services/ProxyService.cs @@ -11,7 +11,7 @@ using Discord.Rest; using Discord.Webhook; using Discord.WebSocket; -namespace PluralKit +namespace PluralKit.Bot { class ProxyDatabaseResult { diff --git a/PluralKit/Bot/Utils.cs b/PluralKit/Bot/Utils.cs new file mode 100644 index 00000000..cf61901f --- /dev/null +++ b/PluralKit/Bot/Utils.cs @@ -0,0 +1,171 @@ +using System; +using System.Data; +using System.Threading.Tasks; +using Dapper; +using Discord; +using Discord.Commands; +using Discord.Commands.Builders; +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; + +namespace PluralKit.Bot +{ + public static class Utils { + public static string NameAndMention(this IUser user) { + return $"{user.Username}#{user.Discriminator} ({user.Mention})"; + } + } + + class UlongEncodeAsLongHandler : SqlMapper.TypeHandler + { + public override ulong Parse(object value) + { + // Cast to long to unbox, then to ulong (???) + return (ulong)(long)value; + } + + public override void SetValue(IDbDataParameter parameter, ulong value) + { + parameter.Value = (long)value; + } + } + + class PKSystemTypeReader : TypeReader + { + public override async Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + var client = services.GetService(); + var conn = services.GetService(); + + // System references can take three forms: + // - The direct user ID of an account connected to the system + // - A @mention of an account connected to the system (<@uid>) + // - A system hid + + // First, try direct user ID parsing + if (ulong.TryParse(input, out var idFromNumber)) return await FindSystemByAccountHelper(idFromNumber, client, conn); + + // Then, try mention parsing. + if (MentionUtils.TryParseUser(input, out var idFromMention)) return await FindSystemByAccountHelper(idFromMention, client, conn); + + // Finally, try HID parsing + var res = await conn.QuerySingleOrDefaultAsync("select * from systems where hid = @Hid", new { Hid = input }); + if (res != null) return TypeReaderResult.FromSuccess(res); + return TypeReaderResult.FromError(CommandError.ObjectNotFound, $"System with ID `{input}` not found."); + } + + async Task FindSystemByAccountHelper(ulong id, IDiscordClient client, IDbConnection conn) + { + var foundByAccountId = await conn.QuerySingleOrDefaultAsync("select * from accounts, systems where accounts.system = system.id and accounts.id = @Id", new { Id = id }); + if (foundByAccountId != null) return TypeReaderResult.FromSuccess(foundByAccountId); + + // We didn't find any, so we try to resolve the user ID to find the associated account, + // so we can print their username. + var user = await client.GetUserAsync(id); + + // Return descriptive errors based on whether we found the user or not. + if (user == null) return TypeReaderResult.FromError(CommandError.ObjectNotFound, $"System or account with ID `{id}` not found."); + return TypeReaderResult.FromError(CommandError.ObjectNotFound, $"Account **{user.Username}#{user.Discriminator}** not found."); + } + } + + class PKMemberTypeReader : TypeReader + { + public override async Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + var conn = services.GetService(typeof(IDbConnection)) as IDbConnection; + + // If the sender of the command is in a system themselves, + // then try searching by the member's name + if (context is PKCommandContext ctx && ctx.SenderSystem != null) + { + var foundByName = await conn.QuerySingleOrDefaultAsync("select * from members where system = @System and lower(name) = lower(@Name)", new { System = ctx.SenderSystem.Id, Name = input }); + if (foundByName != null) return TypeReaderResult.FromSuccess(foundByName); + } + + // Otherwise, if sender isn't in a system, or no member found by that name, + // do a standard by-hid search. + var foundByHid = await conn.QuerySingleOrDefaultAsync("select * from members where hid = @Hid", new { Hid = input }); + if (foundByHid != null) return TypeReaderResult.FromSuccess(foundByHid); + return TypeReaderResult.FromError(CommandError.ObjectNotFound, "Member not found."); + } + } + + /// Subclass of ICommandContext with PK-specific additional fields and functionality + public class PKCommandContext : SocketCommandContext, ICommandContext + { + public IDbConnection Connection { get; } + public PKSystem SenderSystem { get; } + + private object _entity; + + public PKCommandContext(DiscordSocketClient client, SocketUserMessage msg, IDbConnection connection, PKSystem system) : base(client, msg) + { + Connection = connection; + SenderSystem = system; + } + + public T GetContextEntity() where T: class { + return _entity as T; + } + + public void SetContextEntity(object entity) { + _entity = entity; + } + } + + public abstract class ContextParameterModuleBase : ModuleBase where T: class + { + public IServiceProvider _services { get; set; } + public CommandService _commands { get; set; } + + public abstract string Prefix { get; } + public abstract Task ReadContextParameterAsync(string value); + + public T ContextEntity => Context.GetContextEntity(); + + protected override void OnModuleBuilding(CommandService commandService, ModuleBuilder builder) { + // We create a catch-all command that intercepts the first argument, tries to parse it as + // the context parameter, then runs the command service AGAIN with that given in a wrapped + // context, with the context argument removed so it delegates to the subcommand executor + builder.AddCommand("", async (ctx, param, services, info) => { + var pkCtx = ctx as PKCommandContext; + var res = await ReadContextParameterAsync(param[0] as string); + pkCtx.SetContextEntity(res); + + await commandService.ExecuteAsync(pkCtx, Prefix + " " + param[1] as string, services); + }, (cb) => { + cb.WithPriority(-9999); + cb.AddPrecondition(new ContextParameterFallbackPreconditionAttribute()); + cb.AddParameter("contextValue", (pb) => pb.WithDefault("")); + cb.AddParameter("rest", (pb) => pb.WithDefault("").WithIsRemainder(true)); + }); + } + } + + public class ContextParameterFallbackPreconditionAttribute : PreconditionAttribute + { + public ContextParameterFallbackPreconditionAttribute() + { + } + + public override async Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + if (context.GetType().Name != "ContextualContext`1") { + return PreconditionResult.FromSuccess(); + } else { + return PreconditionResult.FromError(""); + } + } + } + + public class PKResult : RuntimeResult + { + public PKResult(CommandError? error, string reason) : base(error, reason) + { + } + + public static RuntimeResult Error(string reason) => new PKResult(CommandError.Unsuccessful, reason); + public static RuntimeResult Success(string reason = null) => new PKResult(null, reason); + } +} \ No newline at end of file diff --git a/PluralKit/Stores.cs b/PluralKit/Stores.cs index c476495c..2b943387 100644 --- a/PluralKit/Stores.cs +++ b/PluralKit/Stores.cs @@ -16,10 +16,14 @@ namespace PluralKit { public async Task Create(string systemName = null) { // TODO: handle HID collision case - var hid = HidUtils.GenerateHid(); + var hid = Utils.GenerateHid(); return await conn.QuerySingleAsync("insert into systems (hid, name) values (@Hid, @Name) returning *", new { Hid = hid, Name = systemName }); } + public async Task Link(PKSystem system, ulong accountId) { + await conn.ExecuteAsync("insert into accounts (uid, system) values (@Id, @SystemId)", new { Id = accountId, SystemId = system.Id }); + } + public async Task GetByAccount(ulong accountId) { return await conn.QuerySingleAsync("select systems.* from systems, accounts where accounts.system = system.id and accounts.uid = @Id", new { Id = accountId }); } @@ -39,6 +43,11 @@ namespace PluralKit { public async Task Delete(PKSystem system) { await conn.DeleteAsync(system); } + + public async Task> GetLinkedAccountIds(PKSystem system) + { + return await conn.QueryAsync("select uid from accounts where system = @Id", new { Id = system.Id }); + } } public class MemberStore { @@ -50,7 +59,7 @@ namespace PluralKit { public async Task Create(PKSystem system, string name) { // TODO: handle collision - var hid = HidUtils.GenerateHid(); + var hid = Utils.GenerateHid(); return await conn.QuerySingleAsync("insert into members (hid, system, name) values (@Hid, @SystemId, @Name) returning *", new { Hid = hid, SystemID = system.Id, diff --git a/PluralKit/Utils.cs b/PluralKit/Utils.cs index 3ae2c64d..5190cee7 100644 --- a/PluralKit/Utils.cs +++ b/PluralKit/Utils.cs @@ -10,146 +10,7 @@ using Microsoft.Extensions.DependencyInjection; namespace PluralKit { - class UlongEncodeAsLongHandler : SqlMapper.TypeHandler - { - public override ulong Parse(object value) - { - // Cast to long to unbox, then to ulong (???) - return (ulong)(long)value; - } - - public override void SetValue(IDbDataParameter parameter, ulong value) - { - parameter.Value = (long)value; - } - } - - class PKSystemTypeReader : TypeReader - { - public override async Task ReadAsync(ICommandContext context, string input, IServiceProvider services) - { - var client = services.GetService(); - var conn = services.GetService(); - - // System references can take three forms: - // - The direct user ID of an account connected to the system - // - A @mention of an account connected to the system (<@uid>) - // - A system hid - - // First, try direct user ID parsing - if (ulong.TryParse(input, out var idFromNumber)) return await FindSystemByAccountHelper(idFromNumber, client, conn); - - // Then, try mention parsing. - if (MentionUtils.TryParseUser(input, out var idFromMention)) return await FindSystemByAccountHelper(idFromMention, client, conn); - - // Finally, try HID parsing - var res = await conn.QuerySingleOrDefaultAsync("select * from systems where hid = @Hid", new { Hid = input }); - if (res != null) return TypeReaderResult.FromSuccess(res); - return TypeReaderResult.FromError(CommandError.ObjectNotFound, $"System with ID `${input}` not found."); - } - - async Task FindSystemByAccountHelper(ulong id, IDiscordClient client, IDbConnection conn) - { - var foundByAccountId = await conn.QuerySingleOrDefaultAsync("select * from accounts, systems where accounts.system = system.id and accounts.id = @Id", new { Id = id }); - if (foundByAccountId != null) return TypeReaderResult.FromSuccess(foundByAccountId); - - // We didn't find any, so we try to resolve the user ID to find the associated account, - // so we can print their username. - var user = await client.GetUserAsync(id); - - // Return descriptive errors based on whether we found the user or not. - if (user == null) return TypeReaderResult.FromError(CommandError.ObjectNotFound, $"System or account with ID `${id}` not found."); - return TypeReaderResult.FromError(CommandError.ObjectNotFound, $"Account **${user.Username}#${user.Discriminator}** not found."); - } - } - - class PKMemberTypeReader : TypeReader - { - public override async Task ReadAsync(ICommandContext context, string input, IServiceProvider services) - { - var conn = services.GetService(typeof(IDbConnection)) as IDbConnection; - - // If the sender of the command is in a system themselves, - // then try searching by the member's name - if (context is PKCommandContext ctx && ctx.SenderSystem != null) - { - var foundByName = await conn.QuerySingleOrDefaultAsync("select * from members where system = @System and lower(name) = lower(@Name)", new { System = ctx.SenderSystem.Id, Name = input }); - if (foundByName != null) return TypeReaderResult.FromSuccess(foundByName); - } - - // Otherwise, if sender isn't in a system, or no member found by that name, - // do a standard by-hid search. - var foundByHid = await conn.QuerySingleOrDefaultAsync("select * from members where hid = @Hid", new { Hid = input }); - if (foundByHid != null) return TypeReaderResult.FromSuccess(foundByHid); - return TypeReaderResult.FromError(CommandError.ObjectNotFound, "Member not found."); - } - } - - /// Subclass of ICommandContext with PK-specific additional fields and functionality - public class PKCommandContext : SocketCommandContext, ICommandContext - { - public IDbConnection Connection { get; } - public PKSystem SenderSystem { get; } - - public PKCommandContext(DiscordSocketClient client, SocketUserMessage msg, IDbConnection connection, PKSystem system) : base(client, msg) - { - Connection = connection; - SenderSystem = system; - } - } - - public class ContextualContext : PKCommandContext - { - public T ContextEntity { get; internal set; } - - public ContextualContext(PKCommandContext ctx, T contextEntity): base(ctx.Client, ctx.Message, ctx.Connection, ctx.SenderSystem) - { - this.ContextEntity = contextEntity; - } - } - - public abstract class ContextParameterModuleBase : ModuleBase> - { - public IServiceProvider _services { get; set; } - public CommandService _commands { get; set; } - - public abstract string Prefix { get; } - public abstract Task ReadContextParameterAsync(string value); - - protected override void OnModuleBuilding(CommandService commandService, ModuleBuilder builder) { - // We create a catch-all command that intercepts the first argument, tries to parse it as - // the context parameter, then runs the command service AGAIN with that given in a wrapped - // context, with the context argument removed so it delegates to the subcommand executor - builder.AddCommand("", async (ctx, param, services, info) => { - var pkCtx = ctx as PKCommandContext; - var res = await ReadContextParameterAsync(param[0] as string); - await commandService.ExecuteAsync(new ContextualContext(pkCtx, res), Prefix + " " + param[1] as string, services); - }, (cb) => { - cb.WithPriority(-9999); - cb.AddPrecondition(new ContextParameterFallbackPreconditionAttribute()); - cb.AddParameter("contextValue", (pb) => pb.WithDefault("")); - cb.AddParameter("rest", (pb) => pb.WithDefault("").WithIsRemainder(true)); - }); - } - } - - public class ContextParameterFallbackPreconditionAttribute : PreconditionAttribute - { - public ContextParameterFallbackPreconditionAttribute() - { - } - - public override async Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) - { - if (context.GetType().Name != "ContextualContext`1") { - return PreconditionResult.FromSuccess(); - } else { - return PreconditionResult.FromError(""); - } - } - } - - public class HidUtils + public static class Utils { public static string GenerateHid() { @@ -162,15 +23,10 @@ namespace PluralKit } return hid; } - } - public class PKResult : RuntimeResult - { - public PKResult(CommandError? error, string reason) : base(error, reason) - { + public static string Truncate(this string str, int maxLength, string ellipsis = "...") { + if (str.Length < maxLength) return str; + return str.Substring(0, maxLength - ellipsis.Length) + ellipsis; } - - public static RuntimeResult Error(string reason) => new PKResult(CommandError.Unsuccessful, reason); - public static RuntimeResult Success(string reason = null) => new PKResult(null, reason); } } \ No newline at end of file