bot: split bot namespace, add system card, fix command handling

This commit is contained in:
Ske 2019-04-21 15:33:22 +02:00
parent c36cee6f28
commit 62cde789cb
8 changed files with 242 additions and 159 deletions

View File

@ -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<Bot>()
.AddSingleton<CommandService>()
.AddSingleton<EmbedService>()
.AddSingleton<LogChannelService>()
.AddSingleton<ProxyService>()
@ -67,7 +68,6 @@ namespace PluralKit
.BuildServiceProvider();
}
class Bot
{
private IServiceProvider _services;

View File

@ -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<PKSystem>
@ -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<RuntimeResult> 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<RuntimeResult> 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<RuntimeResult> 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<RuntimeResult> 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<RuntimeResult> 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;

View File

@ -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<Embed> 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();
}
}
}

View File

@ -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;

View File

@ -11,7 +11,7 @@ using Discord.Rest;
using Discord.Webhook;
using Discord.WebSocket;
namespace PluralKit
namespace PluralKit.Bot
{
class ProxyDatabaseResult
{

171
PluralKit/Bot/Utils.cs Normal file
View File

@ -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<ulong>
{
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<TypeReaderResult> ReadAsync(ICommandContext context, string input, IServiceProvider services)
{
var client = services.GetService<IDiscordClient>();
var conn = services.GetService<IDbConnection>();
// 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<PKSystem>("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<TypeReaderResult> FindSystemByAccountHelper(ulong id, IDiscordClient client, IDbConnection conn)
{
var foundByAccountId = await conn.QuerySingleOrDefaultAsync<PKSystem>("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<TypeReaderResult> 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<PKMember>("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<PKMember>("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<T>() where T: class {
return _entity as T;
}
public void SetContextEntity(object entity) {
_entity = entity;
}
}
public abstract class ContextParameterModuleBase<T> : ModuleBase<PKCommandContext> where T: class
{
public IServiceProvider _services { get; set; }
public CommandService _commands { get; set; }
public abstract string Prefix { get; }
public abstract Task<T> ReadContextParameterAsync(string value);
public T ContextEntity => Context.GetContextEntity<T>();
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<string>("contextValue", (pb) => pb.WithDefault(""));
cb.AddParameter<string>("rest", (pb) => pb.WithDefault("").WithIsRemainder(true));
});
}
}
public class ContextParameterFallbackPreconditionAttribute : PreconditionAttribute
{
public ContextParameterFallbackPreconditionAttribute()
{
}
public override async Task<PreconditionResult> 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);
}
}

View File

@ -16,10 +16,14 @@ namespace PluralKit {
public async Task<PKSystem> Create(string systemName = null) {
// TODO: handle HID collision case
var hid = HidUtils.GenerateHid();
var hid = Utils.GenerateHid();
return await conn.QuerySingleAsync<PKSystem>("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<PKSystem> GetByAccount(ulong accountId) {
return await conn.QuerySingleAsync<PKSystem>("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<IEnumerable<ulong>> GetLinkedAccountIds(PKSystem system)
{
return await conn.QueryAsync<ulong>("select uid from accounts where system = @Id", new { Id = system.Id });
}
}
public class MemberStore {
@ -50,7 +59,7 @@ namespace PluralKit {
public async Task<PKMember> 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,

View File

@ -10,146 +10,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace PluralKit
{
class UlongEncodeAsLongHandler : SqlMapper.TypeHandler<ulong>
{
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<TypeReaderResult> ReadAsync(ICommandContext context, string input, IServiceProvider services)
{
var client = services.GetService<IDiscordClient>();
var conn = services.GetService<IDbConnection>();
// 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<PKSystem>("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<TypeReaderResult> FindSystemByAccountHelper(ulong id, IDiscordClient client, IDbConnection conn)
{
var foundByAccountId = await conn.QuerySingleOrDefaultAsync<PKSystem>("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<TypeReaderResult> 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<PKMember>("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<PKMember>("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<T> : 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<T> : ModuleBase<ContextualContext<T>>
{
public IServiceProvider _services { get; set; }
public CommandService _commands { get; set; }
public abstract string Prefix { get; }
public abstract Task<T> 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<T>(pkCtx, res), Prefix + " " + param[1] as string, services);
}, (cb) => {
cb.WithPriority(-9999);
cb.AddPrecondition(new ContextParameterFallbackPreconditionAttribute());
cb.AddParameter<string>("contextValue", (pb) => pb.WithDefault(""));
cb.AddParameter<string>("rest", (pb) => pb.WithDefault("").WithIsRemainder(true));
});
}
}
public class ContextParameterFallbackPreconditionAttribute : PreconditionAttribute
{
public ContextParameterFallbackPreconditionAttribute()
{
}
public override async Task<PreconditionResult> 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);
}
}