From e7fa5625b6d39aca0e2f64b850f996175ee16a3a Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Fri, 19 Apr 2019 20:48:37 +0200 Subject: [PATCH 001/103] bot: .net rewrite skeleton --- .gitignore | 2 + Dockerfile | 6 + PluralKit.csproj | 16 + PluralKit/Bot.cs | 125 +++++ PluralKit/Commands/SystemCommands.cs | 73 +++ PluralKit/Models.cs | 34 ++ PluralKit/Services/LogChannelService.cs | 50 ++ PluralKit/Services/ProxyService.cs | 151 ++++++ PluralKit/Stores.cs | 130 +++++ PluralKit/Utils.cs | 176 +++++++ docker-compose.yml | 41 +- pluralkit.conf.example | 5 - src/Dockerfile | 10 - src/api_main.py | 254 ---------- src/bot_main.py | 12 - src/pluralkit/__init__.py | 0 src/pluralkit/api/__init__.py | 0 src/pluralkit/bot/__init__.py | 148 ------ src/pluralkit/bot/channel_logger.py | 104 ---- src/pluralkit/bot/commands/__init__.py | 243 ---------- src/pluralkit/bot/commands/api_commands.py | 35 -- src/pluralkit/bot/commands/import_commands.py | 49 -- src/pluralkit/bot/commands/member_commands.py | 192 -------- .../bot/commands/message_commands.py | 18 - src/pluralkit/bot/commands/misc_commands.py | 191 -------- src/pluralkit/bot/commands/mod_commands.py | 21 - src/pluralkit/bot/commands/switch_commands.py | 156 ------ src/pluralkit/bot/commands/system_commands.py | 443 ------------------ src/pluralkit/bot/embeds.py | 285 ----------- src/pluralkit/bot/help.json | 336 ------------- src/pluralkit/bot/help.py | 6 - src/pluralkit/bot/proxy.py | 254 ---------- src/pluralkit/bot/utils.py | 87 ---- src/pluralkit/db.py | 383 --------------- src/pluralkit/errors.py | 104 ---- src/pluralkit/member.py | 177 ------- src/pluralkit/switch.py | 28 -- src/pluralkit/system.py | 322 ------------- src/pluralkit/utils.py | 73 --- src/requirements.txt | 10 - 40 files changed, 770 insertions(+), 3980 deletions(-) create mode 100644 Dockerfile create mode 100644 PluralKit.csproj create mode 100644 PluralKit/Bot.cs create mode 100644 PluralKit/Commands/SystemCommands.cs create mode 100644 PluralKit/Models.cs create mode 100644 PluralKit/Services/LogChannelService.cs create mode 100644 PluralKit/Services/ProxyService.cs create mode 100644 PluralKit/Stores.cs create mode 100644 PluralKit/Utils.cs delete mode 100644 pluralkit.conf.example delete mode 100644 src/Dockerfile delete mode 100644 src/api_main.py delete mode 100644 src/bot_main.py delete mode 100644 src/pluralkit/__init__.py delete mode 100644 src/pluralkit/api/__init__.py delete mode 100644 src/pluralkit/bot/__init__.py delete mode 100644 src/pluralkit/bot/channel_logger.py delete mode 100644 src/pluralkit/bot/commands/__init__.py delete mode 100644 src/pluralkit/bot/commands/api_commands.py delete mode 100644 src/pluralkit/bot/commands/import_commands.py delete mode 100644 src/pluralkit/bot/commands/member_commands.py delete mode 100644 src/pluralkit/bot/commands/message_commands.py delete mode 100644 src/pluralkit/bot/commands/misc_commands.py delete mode 100644 src/pluralkit/bot/commands/mod_commands.py delete mode 100644 src/pluralkit/bot/commands/switch_commands.py delete mode 100644 src/pluralkit/bot/commands/system_commands.py delete mode 100644 src/pluralkit/bot/embeds.py delete mode 100644 src/pluralkit/bot/help.json delete mode 100644 src/pluralkit/bot/help.py delete mode 100644 src/pluralkit/bot/proxy.py delete mode 100644 src/pluralkit/bot/utils.py delete mode 100644 src/pluralkit/db.py delete mode 100644 src/pluralkit/errors.py delete mode 100644 src/pluralkit/member.py delete mode 100644 src/pluralkit/switch.py delete mode 100644 src/pluralkit/system.py delete mode 100644 src/pluralkit/utils.py delete mode 100644 src/requirements.txt diff --git a/.gitignore b/.gitignore index c6dafd0c..5f36b626 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +bin/ +obj/ .env .vscode/ .idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..1e5014fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM mcr.microsoft.com/dotnet/core/sdk:2.2-alpine + +WORKDIR /app +COPY PluralKit/ PluralKit.csproj /app/ +RUN dotnet build +ENTRYPOINT ["dotnet", "run"] \ No newline at end of file diff --git a/PluralKit.csproj b/PluralKit.csproj new file mode 100644 index 00000000..2d568ed3 --- /dev/null +++ b/PluralKit.csproj @@ -0,0 +1,16 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>netcoreapp2.2</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Dapper" Version="1.60.1" /> + <PackageReference Include="Dapper.Contrib" Version="1.60.1" /> + <PackageReference Include="Discord.Net" Version="2.0.1" /> + <PackageReference Include="Npgsql" Version="4.0.4" /> + <PackageReference Include="Npgsql.Json.NET" Version="4.0.4" /> + </ItemGroup> + +</Project> diff --git a/PluralKit/Bot.cs b/PluralKit/Bot.cs new file mode 100644 index 00000000..128cefa8 --- /dev/null +++ b/PluralKit/Bot.cs @@ -0,0 +1,125 @@ +using System; +using System.Data; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Dapper; +using Discord; +using Discord.Commands; +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using Npgsql.BackendMessages; +using Npgsql.PostgresTypes; +using Npgsql.TypeHandling; +using Npgsql.TypeMapping; +using NpgsqlTypes; + +namespace PluralKit +{ + class Initialize + { + static void Main() => new Initialize().MainAsync().GetAwaiter().GetResult(); + + private async Task MainAsync() + { + // Dapper by default tries to pass ulongs to Npgsql, which rejects them since PostgreSQL technically + // doesn't support unsigned types on its own. + // Instead we add a custom mapper to encode them as signed integers instead, converting them back and forth. + SqlMapper.RemoveTypeMap(typeof(ulong)); + SqlMapper.AddTypeHandler<ulong>(new UlongEncodeAsLongHandler()); + Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + + using (var services = BuildServiceProvider()) + { + var connection = services.GetRequiredService<IDbConnection>() as NpgsqlConnection; + connection.ConnectionString = Environment.GetEnvironmentVariable("PK_DATABASE_URI"); + await connection.OpenAsync(); + + var client = services.GetRequiredService<IDiscordClient>() as DiscordSocketClient; + await client.LoginAsync(TokenType.Bot, Environment.GetEnvironmentVariable("PK_TOKEN")); + await client.StartAsync(); + + await services.GetRequiredService<Bot>().Init(); + await Task.Delay(-1); + } + } + + public ServiceProvider BuildServiceProvider() => new ServiceCollection() + .AddSingleton<IDiscordClient, DiscordSocketClient>() + .AddSingleton<IDbConnection, NpgsqlConnection>() + .AddSingleton<Bot>() + + .AddSingleton<CommandService>() + .AddSingleton<LogChannelService>() + .AddSingleton<ProxyService>() + + .AddSingleton<SystemStore>() + .AddSingleton<MemberStore>() + .AddSingleton<MessageStore>() + .BuildServiceProvider(); + } + + + class Bot + { + private IServiceProvider _services; + private DiscordSocketClient _client; + private CommandService _commands; + private IDbConnection _connection; + private ProxyService _proxy; + + public Bot(IServiceProvider services, IDiscordClient client, CommandService commands, IDbConnection connection, ProxyService proxy) + { + this._services = services; + this._client = client as DiscordSocketClient; + this._commands = commands; + this._connection = connection; + this._proxy = proxy; + } + + public async Task Init() + { + _commands.AddTypeReader<PKSystem>(new PKSystemTypeReader()); + _commands.AddTypeReader<PKMember>(new PKMemberTypeReader()); + _commands.CommandExecuted += CommandExecuted; + await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services); + + _client.MessageReceived += MessageReceived; + _client.ReactionAdded += _proxy.HandleReactionAddedAsync; + _client.MessageDeleted += _proxy.HandleMessageDeletedAsync; + } + + private async Task CommandExecuted(Optional<CommandInfo> cmd, ICommandContext ctx, IResult _result) + { + if (!_result.IsSuccess) { + await ctx.Message.Channel.SendMessageAsync("\u274C " + _result.ErrorReason); + } + } + + private async Task MessageReceived(SocketMessage _arg) + { + // Ignore system messages (member joined, message pinned, etc) + var arg = _arg as SocketUserMessage; + if (arg == null) return; + + // Ignore bot messages + if (arg.Author.IsBot || arg.Author.IsWebhook) return; + + int argPos = 0; + // Check if message starts with the command prefix + if (arg.HasStringPrefix("pk;", ref argPos) || arg.HasStringPrefix("pk!", ref argPos) || arg.HasMentionPrefix(_client.CurrentUser, ref argPos)) + { + // If it does, fetch the sender's system (because most commands need that) into the context, + // and start command execution + var system = await _connection.QueryFirstAsync<PKSystem>("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id }); + await _commands.ExecuteAsync(new PKCommandContext(_client, arg as SocketUserMessage, _connection, system), argPos, _services); + } + else + { + // If not, try proxying anyway + await _proxy.HandleMessageAsync(arg); + } + } + } +} \ No newline at end of file diff --git a/PluralKit/Commands/SystemCommands.cs b/PluralKit/Commands/SystemCommands.cs new file mode 100644 index 00000000..5bafc086 --- /dev/null +++ b/PluralKit/Commands/SystemCommands.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading.Tasks; +using Dapper; +using Discord.Commands; + +namespace PluralKit.Commands +{ + [Group("system")] + public class SystemCommands : ContextParameterModuleBase<PKSystem> + { + public override string Prefix => "system"; + public SystemStore Systems {get; set;} + public MemberStore Members {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("new")] + public async Task<RuntimeResult> New([Remainder] string systemName = null) + { + if (Context.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 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 (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)"); + + Context.SenderSystem.Name = newSystemName; + await Systems.Save(Context.SenderSystem); + return PKResult.Success(); + } + + [Command("description")] + public async Task<RuntimeResult> Description([Remainder] string newDescription = null) { + if (Context.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)"); + + Context.SenderSystem.Description = newDescription; + await Systems.Save(Context.SenderSystem); + return PKResult.Success("uwu"); + } + + [Command("tag")] + public async Task<RuntimeResult> Tag([Remainder] string newTag = null) { + if (Context.ContextEntity != null) return OTHER_SYSTEM_CONTEXT_ERROR; + if (Context.SenderSystem == null) return NO_SYSTEM_ERROR; + + Context.SenderSystem.Tag = newTag; + + var unproxyableMembers = await Members.GetUnproxyableMembers(Context.SenderSystem); + //if (unproxyableMembers.Count > 0) { + throw new Exception("sdjsdflsdf"); + //} + + await Systems.Save(Context.SenderSystem); + return PKResult.Success("uwu"); + } + + public override async Task<PKSystem> ReadContextParameterAsync(string value) + { + var res = await new PKSystemTypeReader().ReadAsync(Context, value, _services); + return res.IsSuccess ? res.BestMatch as PKSystem : null; + } + } +} \ No newline at end of file diff --git a/PluralKit/Models.cs b/PluralKit/Models.cs new file mode 100644 index 00000000..cc985abe --- /dev/null +++ b/PluralKit/Models.cs @@ -0,0 +1,34 @@ +using System; +using Dapper.Contrib.Extensions; + +namespace PluralKit { + [Table("systems")] + public class PKSystem { + [Key] + public int Id { get; set; } + public string Hid { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string Tag { get; set; } + public string AvatarUrl { get; set; } + public string Token { get; set; } + public DateTime Created { get; set; } + public string UiTz { get; set; } + } + + [Table("members")] + public class PKMember { + public int Id { get; set; } + public string Hid { get; set; } + public int System { get; set; } + public string Color { get; set; } + public string AvatarUrl { get; set; } + public string Name { get; set; } + public DateTime Date { get; set; } + public string Pronouns { get; set; } + public string Description { get; set; } + public string Prefix { get; set; } + public string Suffix { get; set; } + public DateTime Created { get; set; } + } +} \ No newline at end of file diff --git a/PluralKit/Services/LogChannelService.cs b/PluralKit/Services/LogChannelService.cs new file mode 100644 index 00000000..3eee54d4 --- /dev/null +++ b/PluralKit/Services/LogChannelService.cs @@ -0,0 +1,50 @@ +using System.Data; +using System.Threading.Tasks; +using Dapper; +using Discord; + +namespace PluralKit { + class ServerDefinition { + public ulong Id; + public ulong LogChannel; + } + + class LogChannelService { + private IDiscordClient _client; + private IDbConnection _connection; + + public LogChannelService(IDiscordClient client, IDbConnection connection) + { + this._client = client; + this._connection = connection; + } + + public async Task LogMessage(PKSystem system, PKMember member, IMessage message, IUser sender) { + var channel = await GetLogChannel((message.Channel as IGuildChannel).Guild); + if (channel == null) return; + + var embed = new EmbedBuilder() + .WithAuthor($"#{message.Channel.Name}: {member.Name}", member.AvatarUrl) + .WithDescription(message.Content) + .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: ${sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: ${message.Id}") + .WithTimestamp(message.Timestamp) + .Build(); + await channel.SendMessageAsync(text: message.GetJumpUrl(), embed: embed); + } + + public async Task<ITextChannel> GetLogChannel(IGuild guild) { + var server = await _connection.QueryFirstAsync<ServerDefinition>("select * from servers where id = @Id", new { Id = guild.Id }); + if (server == null) return null; + return await _client.GetChannelAsync(server.LogChannel) as ITextChannel; + } + + public async Task SetLogChannel(IGuild guild, ITextChannel newLogChannel) { + var def = new ServerDefinition { + Id = guild.Id, + LogChannel = newLogChannel.Id + }; + + await _connection.ExecuteAsync("insert into servers(id, log_channel) values (@Id, @LogChannel) on conflict (id) do update set log_channel = @LogChannel", def); + } + } +} \ No newline at end of file diff --git a/PluralKit/Services/ProxyService.cs b/PluralKit/Services/ProxyService.cs new file mode 100644 index 00000000..c8f2bf42 --- /dev/null +++ b/PluralKit/Services/ProxyService.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Dapper; +using Discord; +using Discord.Rest; +using Discord.Webhook; +using Discord.WebSocket; + +namespace PluralKit +{ + class ProxyDatabaseResult + { + public PKSystem System; + public PKMember Member; + } + + class ProxyMatch { + public PKMember Member; + public PKSystem System; + public string InnerText; + + public string ProxyName => Member.Name + (System.Tag.Length > 0 ? " " + System.Tag : ""); + } + + class ProxyService { + private IDiscordClient _client; + private IDbConnection _connection; + private LogChannelService _logger; + private MessageStore _messageStorage; + + private ConcurrentDictionary<ulong, Lazy<Task<IWebhook>>> _webhooks; + + public ProxyService(IDiscordClient client, IDbConnection connection, LogChannelService logger, MessageStore messageStorage) + { + this._client = client; + this._connection = connection; + this._logger = logger; + this._messageStorage = messageStorage; + + _webhooks = new ConcurrentDictionary<ulong, Lazy<Task<IWebhook>>>(); + } + + private ProxyMatch GetProxyTagMatch(string message, IEnumerable<ProxyDatabaseResult> potentials) { + // TODO: add detection of leading @mention + + // Sort by specificity (prefix+suffix first, prefix/suffix second) + var ordered = potentials.OrderByDescending((p) => (p.Member.Prefix != null ? 0 : 1) + (p.Member.Suffix != null ? 0 : 1)); + foreach (var potential in ordered) { + var prefix = potential.Member.Prefix ?? ""; + var suffix = potential.Member.Suffix ?? ""; + + if (message.StartsWith(prefix) && message.EndsWith(suffix)) { + var inner = message.Substring(prefix.Length, message.Length - prefix.Length - suffix.Length); + return new ProxyMatch { Member = potential.Member, System = potential.System, InnerText = inner }; + } + } + return null; + } + + public async Task HandleMessageAsync(IMessage message) { + var results = await _connection.QueryAsync<PKMember, PKSystem, ProxyDatabaseResult>("select members.*, systems.* from members, systems, accounts where members.system = systems.id and accounts.system = systems.id and accounts.uid = @Uid", (member, system) => new ProxyDatabaseResult { Member = member, System = system }, new { Uid = message.Author.Id }); + + // Find a member with proxy tags matching the message + var match = GetProxyTagMatch(message.Content, results); + if (match == null) return; + + // Fetch a webhook for this channel, and send the proxied message + var webhook = await GetWebhookByChannelCaching(message.Channel as ITextChannel); + var hookMessage = await ExecuteWebhook(webhook, match.InnerText, match.ProxyName, match.Member.AvatarUrl, message.Attachments.FirstOrDefault()); + + // Store the message in the database, and log it in the log channel (if applicable) + await _messageStorage.Store(message.Author.Id, hookMessage.Id, hookMessage.Channel.Id, match.Member); + await _logger.LogMessage(match.System, match.Member, hookMessage, message.Author); + + // Wait a second or so before deleting the original message + await Task.Delay(1000); + await message.DeleteAsync(); + } + + private async Task<IMessage> ExecuteWebhook(IWebhook webhook, string text, string username, string avatarUrl, IAttachment attachment) { + var client = new DiscordWebhookClient(webhook); + + ulong messageId; + if (attachment != null) { + using (var stream = await WebRequest.CreateHttp(attachment.Url).GetRequestStreamAsync()) { + messageId = await client.SendFileAsync(stream, filename: attachment.Filename, text: text, username: username, avatarUrl: avatarUrl); + } + } else { + messageId = await client.SendMessageAsync(text, username: username, avatarUrl: avatarUrl); + } + return await webhook.Channel.GetMessageAsync(messageId); + } + + private async Task<IWebhook> GetWebhookByChannelCaching(ITextChannel channel) { + // We cache the webhook through a Lazy<Task<T>>, this way we make sure to only create one webhook per channel + // TODO: make sure this is sharding-safe. Intuition says yes, since one channel is guaranteed to only be handled by one shard, but best to make sure + var webhookFactory = _webhooks.GetOrAdd(channel.Id, new Lazy<Task<IWebhook>>(() => FindWebhookByChannel(channel))); + return await webhookFactory.Value; + } + + private async Task<IWebhook> FindWebhookByChannel(ITextChannel channel) { + IWebhook webhook; + + webhook = (await channel.GetWebhooksAsync()).FirstOrDefault(IsWebhookMine); + if (webhook != null) return webhook; + + webhook = await channel.CreateWebhookAsync("PluralKit Proxy Webhook"); + return webhook; + } + + private bool IsWebhookMine(IWebhook arg) + { + return arg.Creator.Id == this._client.CurrentUser.Id && arg.Name == "PluralKit Proxy Webhook"; + } + + public async Task HandleReactionAddedAsync(Cacheable<IUserMessage, ulong> message, ISocketMessageChannel channel, SocketReaction reaction) + { + // Make sure it's the right emoji (red X) + if (reaction.Emote.Name != "\u274C") return; + + // Find the message in the database + var storedMessage = await _messageStorage.Get(message.Id); + if (storedMessage == null) return; // (if we can't, that's ok, no worries) + + // Make sure it's the actual sender of that message deleting the message + if (storedMessage.SenderId != reaction.UserId) return; + + try { + // Then, fetch the Discord message and delete that + // TODO: this could be faster if we didn't bother fetching it and just deleted it directly + // somehow through REST? + await (await message.GetOrDownloadAsync()).DeleteAsync(); + } catch (NullReferenceException) { + // Message was deleted before we got to it... cool, no problem, lmao + } + + // Finally, delete it from our database. + await _messageStorage.Delete(message.Id); + } + + public async Task HandleMessageDeletedAsync(Cacheable<IMessage, ulong> message, ISocketMessageChannel channel) + { + await _messageStorage.Delete(message.Id); + } + } +} \ No newline at end of file diff --git a/PluralKit/Stores.cs b/PluralKit/Stores.cs new file mode 100644 index 00000000..c476495c --- /dev/null +++ b/PluralKit/Stores.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Dapper.Contrib.Extensions; + +namespace PluralKit { + public class SystemStore { + private IDbConnection conn; + + public SystemStore(IDbConnection conn) { + this.conn = conn; + } + + public async Task<PKSystem> Create(string systemName = null) { + // TODO: handle HID collision case + var hid = HidUtils.GenerateHid(); + return await conn.QuerySingleAsync<PKSystem>("insert into systems (hid, name) values (@Hid, @Name) returning *", new { Hid = hid, Name = systemName }); + } + + 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 }); + } + + public async Task<PKSystem> GetByHid(string hid) { + return await conn.QuerySingleAsync<PKSystem>("select * from systems where systems.hid = @Hid", new { Hid = hid.ToLower() }); + } + + public async Task<PKSystem> GetByToken(string token) { + return await conn.QuerySingleAsync<PKSystem>("select * from systems where token = @Token", new { Token = token }); + } + + public async Task Save(PKSystem system) { + await conn.UpdateAsync(system); + } + + public async Task Delete(PKSystem system) { + await conn.DeleteAsync(system); + } + } + + public class MemberStore { + private IDbConnection conn; + + public MemberStore(IDbConnection conn) { + this.conn = conn; + } + + public async Task<PKMember> Create(PKSystem system, string name) { + // TODO: handle collision + var hid = HidUtils.GenerateHid(); + return await conn.QuerySingleAsync("insert into members (hid, system, name) values (@Hid, @SystemId, @Name) returning *", new { + Hid = hid, + SystemID = system.Id, + Name = name + }); + } + + public async Task<PKMember> GetByHid(string hid) { + return await conn.QuerySingleAsync("select * from members where hid = @Hid", new { Hid = hid.ToLower() }); + } + + public async Task<PKMember> GetByName(string name) { + return await conn.QuerySingleAsync("select * from members where lower(name) = lower(@Name)", new { Name = name }); + } + + public async Task<PKMember> GetByNameConstrained(PKSystem system, string name) { + return await conn.QuerySingleAsync("select * from members where lower(name) = @Name and system = @SystemID", new { Name = name, SystemID = system.Id }); + } + + public async Task<ICollection<PKMember>> GetUnproxyableMembers(PKSystem system) { + return (await GetBySystem(system)) + .Where((m) => { + var proxiedName = $"{m.Name} {system.Tag}"; + return proxiedName.Length > 32 || proxiedName.Length < 2; + }).ToList(); + } + + public async Task<IEnumerable<PKMember>> GetBySystem(PKSystem system) { + return await conn.QueryAsync<PKMember>("select * from members where system = @SystemID", new { SystemID = system.Id }); + } + + public async Task Save(PKMember member) { + await conn.UpdateAsync(member); + } + + public async Task Delete(PKMember member) { + await conn.DeleteAsync(member); + } + } + + public class MessageStore { + public class StoredMessage { + public ulong Mid; + public ulong ChannelId; + public ulong SenderId; + public PKMember Member; + public PKSystem System; + } + + private IDbConnection _connection; + + public MessageStore(IDbConnection connection) { + this._connection = connection; + } + + public async Task Store(ulong senderId, ulong messageId, ulong channelId, PKMember member) { + await _connection.ExecuteAsync("insert into messages(mid, channel, member, sender) values(@MessageId, @ChannelId, @MemberId, @SenderId)", new { + MessageId = messageId, + ChannelId = channelId, + MemberId = member.Id, + SenderId = senderId + }); + } + + public async Task<StoredMessage> Get(ulong id) { + return (await _connection.QueryAsync<StoredMessage, PKMember, PKSystem, StoredMessage>("select * from messages, members, systems where mid = @Id and messages.member = members.id and systems.id = members.system", (msg, member, system) => { + msg.System = system; + msg.Member = member; + return msg; + }, new { Id = id })).First(); + } + + public async Task Delete(ulong id) { + await _connection.ExecuteAsync("delete from messages where mid = @Id", new { Id = id }); + } + } +} \ No newline at end of file diff --git a/PluralKit/Utils.cs b/PluralKit/Utils.cs new file mode 100644 index 00000000..3ae2c64d --- /dev/null +++ b/PluralKit/Utils.cs @@ -0,0 +1,176 @@ +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 +{ + 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 string GenerateHid() + { + var rnd = new Random(); + var charset = "abcdefghijklmnopqrstuvwxyz"; + string hid = ""; + for (int i = 0; i < 5; i++) + { + hid += charset[rnd.Next(charset.Length)]; + } + return hid; + } + } + + 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/docker-compose.yml b/docker-compose.yml index 8987a6fb..be4ca6e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,38 +1,11 @@ -version: '3' +version: "3" services: bot: - build: src/ - entrypoint: - - python - - bot_main.py - volumes: - - "./pluralkit.conf:/app/pluralkit.conf:ro" + build: . environment: - - "DATABASE_URI=postgres://postgres:postgres@db:5432/postgres" - depends_on: - - db - restart: always - api: - build: src/ - entrypoint: - - python - - api_main.py - depends_on: - - db - restart: always - ports: - - "2939:8080" - environment: - - "DATABASE_URI=postgres://postgres:postgres@db:5432/postgres" - - "CLIENT_ID" - - "INVITE_CLIENT_ID_OVERRIDE" - - "CLIENT_SECRET" - - "REDIRECT_URI" + - PK_TOKEN + - "PK_DATABASE_URI=Host=db;Username=postgres;Password=postgres;Database=postgres" + links: + - db db: - image: postgres:alpine - volumes: - - "db_data:/var/lib/postgresql/data" - restart: always - -volumes: - db_data: \ No newline at end of file + image: postgres:alpine \ No newline at end of file diff --git a/pluralkit.conf.example b/pluralkit.conf.example deleted file mode 100644 index 86339d5a..00000000 --- a/pluralkit.conf.example +++ /dev/null @@ -1,5 +0,0 @@ -{ - "database_uri": "postgres://username:password@hostname:port/database_name", - "token": "BOT_TOKEN_GOES_HERE", - "log_channel": null -} \ No newline at end of file diff --git a/src/Dockerfile b/src/Dockerfile deleted file mode 100644 index 96ab5a06..00000000 --- a/src/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM python:3.6-alpine - -RUN apk --no-cache add build-base libffi-dev - -WORKDIR /app -ADD requirements.txt /app -RUN pip install --trusted-host pypi.python.org -r requirements.txt - -ADD . /app - diff --git a/src/api_main.py b/src/api_main.py deleted file mode 100644 index c0208f9b..00000000 --- a/src/api_main.py +++ /dev/null @@ -1,254 +0,0 @@ -import json -import logging -import os - -from aiohttp import web, ClientSession - -from pluralkit import db, utils -from pluralkit.errors import PluralKitError -from pluralkit.member import Member -from pluralkit.system import System - -logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s") -logger = logging.getLogger("pluralkit.api") - -def require_system(f): - async def inner(request): - if "system" not in request: - raise web.HTTPUnauthorized() - return await f(request) - return inner - -@web.middleware -async def error_middleware(request, handler): - try: - return await handler(request) - except json.JSONDecodeError: - raise web.HTTPBadRequest() - except PluralKitError as e: - return web.json_response({"error": e.message}, status=400) - -@web.middleware -async def db_middleware(request, handler): - async with request.app["pool"].acquire() as conn: - request["conn"] = conn - return await handler(request) - -@web.middleware -async def auth_middleware(request, handler): - token = request.headers.get("X-Token") or request.query.get("token") - if token: - system = await System.get_by_token(request["conn"], token) - if system: - request["system"] = system - return await handler(request) - -@web.middleware -async def cors_middleware(request, handler): - try: - resp = await handler(request) - except web.HTTPException as r: - resp = r - resp.headers["Access-Control-Allow-Origin"] = "*" - resp.headers["Access-Control-Allow-Methods"] = "GET, POST, PATCH" - resp.headers["Access-Control-Allow-Headers"] = "X-Token" - return resp - -class Handlers: - @require_system - async def get_system(request): - return web.json_response(request["system"].to_json()) - - async def get_other_system(request): - system_id = request.match_info.get("system") - system = await System.get_by_hid(request["conn"], system_id) - if not system: - raise web.HTTPNotFound(body="null") - return web.json_response(system.to_json()) - - async def get_system_members(request): - system_id = request.match_info.get("system") - system = await System.get_by_hid(request["conn"], system_id) - if not system: - raise web.HTTPNotFound(body="null") - - members = await system.get_members(request["conn"]) - return web.json_response([m.to_json() for m in members]) - - async def get_system_switches(request): - system_id = request.match_info.get("system") - system = await System.get_by_hid(request["conn"], system_id) - if not system: - raise web.HTTPNotFound(body="null") - - switches = await system.get_switches(request["conn"], 9999) - - cache = {} - async def hid_getter(member_id): - if not member_id in cache: - cache[member_id] = await Member.get_member_by_id(request["conn"], member_id) - return cache[member_id].hid - - return web.json_response([await s.to_json(hid_getter) for s in switches]) - - async def get_system_fronters(request): - system_id = request.match_info.get("system") - system = await System.get_by_hid(request["conn"], system_id) - - if not system: - raise web.HTTPNotFound(body="null") - - members, stamp = await utils.get_fronters(request["conn"], system.id) - if not stamp: - # No switch has been registered at all - raise web.HTTPNotFound(body="null") - - data = { - "timestamp": stamp.isoformat(), - "members": [member.to_json() for member in members] - } - return web.json_response(data) - - @require_system - async def patch_system(request): - req = await request.json() - if "name" in req: - await request["system"].set_name(request["conn"], req["name"]) - if "description" in req: - await request["system"].set_description(request["conn"], req["description"]) - if "tag" in req: - await request["system"].set_tag(request["conn"], req["tag"]) - if "avatar_url" in req: - await request["system"].set_avatar(request["conn"], req["name"]) - if "tz" in req: - await request["system"].set_time_zone(request["conn"], req["tz"]) - return web.json_response((await System.get_by_id(request["conn"], request["system"].id)).to_json()) - - async def get_member(request): - member_id = request.match_info.get("member") - member = await Member.get_member_by_hid(request["conn"], None, member_id) - if not member: - raise web.HTTPNotFound(body="{}") - system = await System.get_by_id(request["conn"], member.system) - member_json = member.to_json() - member_json["system"] = system.to_json() - return web.json_response(member_json) - - @require_system - async def post_member(request): - req = await request.json() - member = await request["system"].create_member(request["conn"], req["name"]) - return web.json_response(member.to_json()) - - @require_system - async def patch_member(request): - member_id = request.match_info.get("member") - member = await Member.get_member_by_hid(request["conn"], None, member_id) - if not member: - raise web.HTTPNotFound() - if member.system != request["system"].id: - raise web.HTTPUnauthorized() - - req = await request.json() - if "name" in req: - await member.set_name(request["conn"], req["name"]) - if "description" in req: - await member.set_description(request["conn"], req["description"]) - if "avatar_url" in req: - await member.set_avatar_url(request["conn"], req["avatar_url"]) - if "color" in req: - await member.set_color(request["conn"], req["color"]) - if "birthday" in req: - await member.set_birthdate(request["conn"], req["birthday"]) - if "pronouns" in req: - await member.set_pronouns(request["conn"], req["pronouns"]) - if "prefix" in req or "suffix" in req: - await member.set_proxy_tags(request["conn"], req.get("prefix", member.prefix), req.get("suffix", member.suffix)) - return web.json_response((await Member.get_member_by_id(request["conn"], member.id)).to_json()) - - @require_system - async def delete_member(request): - member_id = request.match_info.get("member") - member = await Member.get_member_by_hid(request["conn"], None, member_id) - if not member: - raise web.HTTPNotFound() - if member.system != request["system"].id: - raise web.HTTPUnauthorized() - - await member.delete(request["conn"]) - - @require_system - async def post_switch(request): - req = await request.json() - if isinstance(req, str): - req = [req] - if req is None: - req = [] - if not isinstance(req, list): - raise web.HTTPBadRequest() - - members = [await Member.get_member_by_hid(request["conn"], request["system"].id, hid) for hid in req] - if not all(members): - raise web.HTTPNotFound(body=json.dumps({"error": "One or more members not found."})) - - switch = await request["system"].add_switch(request["conn"], members) - - hids = {member.id: member.hid for member in members} - async def hid_getter(mid): - return hids[mid] - - return web.json_response(await switch.to_json(hid_getter)) - - async def discord_oauth(request): - code = await request.text() - async with ClientSession() as sess: - data = { - 'client_id': os.environ["CLIENT_ID"], - 'client_secret': os.environ["CLIENT_SECRET"], - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': os.environ["REDIRECT_URI"], - 'scope': 'identify' - } - headers = { - 'Content-Type': 'application/x-www-form-urlencoded' - } - res = await sess.post("https://discordapp.com/api/v6/oauth2/token", data=data, headers=headers) - if res.status != 200: - raise web.HTTPBadRequest() - - access_token = (await res.json())["access_token"] - res = await sess.get("https://discordapp.com/api/v6/users/@me", headers={"Authorization": "Bearer " + access_token}) - user_id = int((await res.json())["id"]) - - system = await System.get_by_account(request["conn"], user_id) - if not system: - raise web.HTTPUnauthorized() - return web.Response(text=await system.get_token(request["conn"])) - -async def run(): - app = web.Application(middlewares=[cors_middleware, db_middleware, auth_middleware, error_middleware]) - def cors_fallback(req): - return web.Response(headers={"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "x-token", "Access-Control-Allow-Methods": "GET, POST, PATCH"}, status=404 if req.method != "OPTIONS" else 200) - app.add_routes([ - web.get("/s", Handlers.get_system), - web.post("/s/switches", Handlers.post_switch), - web.get("/s/{system}", Handlers.get_other_system), - web.get("/s/{system}/members", Handlers.get_system_members), - web.get("/s/{system}/switches", Handlers.get_system_switches), - web.get("/s/{system}/fronters", Handlers.get_system_fronters), - web.patch("/s", Handlers.patch_system), - web.get("/m/{member}", Handlers.get_member), - web.post("/m", Handlers.post_member), - web.patch("/m/{member}", Handlers.patch_member), - web.delete("/m/{member}", Handlers.delete_member), - web.post("/discord_oauth", Handlers.discord_oauth), - web.route("*", "/{tail:.*}", cors_fallback) - ]) - app["pool"] = await db.connect( - os.environ["DATABASE_URI"] - ) - return app - - -web.run_app(run()) diff --git a/src/bot_main.py b/src/bot_main.py deleted file mode 100644 index 211c162b..00000000 --- a/src/bot_main.py +++ /dev/null @@ -1,12 +0,0 @@ -import asyncio -import sys - -try: - # uvloop doesn't work on Windows, therefore an optional dependency - import uvloop - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) -except ImportError: - pass - -from pluralkit import bot -bot.run(bot.Config.from_file_and_env(sys.argv[1] if len(sys.argv) > 1 else "pluralkit.conf")) \ No newline at end of file diff --git a/src/pluralkit/__init__.py b/src/pluralkit/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/pluralkit/api/__init__.py b/src/pluralkit/api/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/pluralkit/bot/__init__.py b/src/pluralkit/bot/__init__.py deleted file mode 100644 index ef2d062e..00000000 --- a/src/pluralkit/bot/__init__.py +++ /dev/null @@ -1,148 +0,0 @@ -import asyncio -import sys - -import asyncpg -from collections import namedtuple -import discord -import logging -import json -import os -import traceback - -from pluralkit import db -from pluralkit.bot import commands, proxy, channel_logger, embeds - -logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s") - -class Config: - required_fields = ["database_uri", "token"] - fields = ["database_uri", "token", "log_channel"] - - database_uri: str - token: str - log_channel: str - - def __init__(self, database_uri: str, token: str, log_channel: str = None): - self.database_uri = database_uri - self.token = token - self.log_channel = log_channel - - @staticmethod - def from_file_and_env(filename: str) -> "Config": - try: - with open(filename, "r") as f: - config = json.load(f) - except IOError as e: - # If all the required fields are specified as environment variables, it's OK to - # not raise the IOError, we can just construct the dict from these - if all([rf.upper() in os.environ for rf in Config.required_fields]): - config = {} - else: - # If they aren't, though, then rethrow - raise e - - # Override with environment variables - for f in Config.fields: - if f.upper() in os.environ: - config[f] = os.environ[f.upper()] - - # If we currently don't have all the required fields, then raise - if not all([rf in config for rf in Config.required_fields]): - raise RuntimeError("Some required config fields were missing: " + ", ".join(filter(lambda rf: rf not in config, Config.required_fields))) - - return Config(**config) - - -def connect_to_database(uri: str) -> asyncpg.pool.Pool: - return asyncio.get_event_loop().run_until_complete(db.connect(uri)) - - -def run(config: Config): - pool = connect_to_database(config.database_uri) - - async def create_tables(): - async with pool.acquire() as conn: - await db.create_tables(conn) - - asyncio.get_event_loop().run_until_complete(create_tables()) - - client = discord.AutoShardedClient() - logger = channel_logger.ChannelLogger(client) - - @client.event - async def on_ready(): - print("PluralKit started.") - print("User: {}#{} (ID: {})".format(client.user.name, client.user.discriminator, client.user.id)) - print("{} servers".format(len(client.guilds))) - print("{} shards".format(client.shard_count or 1)) - - await client.change_presence(activity=discord.Game(name="pk;help \u2014 in {} servers".format(len(client.guilds)))) - - @client.event - async def on_message(message: discord.Message): - # Ignore messages from bots - if message.author.bot: - return - - # Grab a database connection from the pool - async with pool.acquire() as conn: - # First pass: do command handling - did_run_command = await commands.command_dispatch(client, message, conn) - if did_run_command: - return - - # Second pass: do proxy matching - await proxy.try_proxy_message(conn, message, logger, client.user) - - @client.event - async def on_raw_message_delete(payload: discord.RawMessageDeleteEvent): - async with pool.acquire() as conn: - await proxy.handle_deleted_message(conn, client, payload.message_id, None, logger) - - @client.event - async def on_raw_bulk_message_delete(payload: discord.RawBulkMessageDeleteEvent): - async with pool.acquire() as conn: - for message_id in payload.message_ids: - await proxy.handle_deleted_message(conn, client, message_id, None, logger) - - @client.event - async def on_raw_reaction_add(payload: discord.RawReactionActionEvent): - if payload.emoji.name == "\u274c": # Red X - async with pool.acquire() as conn: - await proxy.try_delete_by_reaction(conn, client, payload.message_id, payload.user_id, logger) - if payload.emoji.name in "\u2753\u2754": # Question mark - async with pool.acquire() as conn: - await proxy.do_query_message(conn, client, payload.user_id, payload.message_id) - - @client.event - async def on_error(event_name, *args, **kwargs): - # Print it to stderr - logging.getLogger("pluralkit").exception("Exception while handling event {}".format(event_name)) - - # Then log it to the given log channel - # TODO: replace this with Sentry or something - if not config.log_channel: - return - log_channel = client.get_channel(int(config.log_channel)) - - # If this is a message event, we can attach additional information in an event - # ie. username, channel, content, etc - if args and isinstance(args[0], discord.Message): - message: discord.Message = args[0] - embed = embeds.exception_log( - message.content, - message.author.name, - message.author.discriminator, - message.author.id, - message.guild.id if message.guild else None, - message.channel.id - ) - else: - # If not, just post the string itself - embed = None - - traceback_str = "```python\n{}```".format(traceback.format_exc()) - if len(traceback.format_exc()) >= (2000 - len("```python\n```")): - traceback_str = "```python\n...{}```".format(traceback.format_exc()[- (2000 - len("```python\n...```")):]) - await log_channel.send(content=traceback_str, embed=embed) - client.run(config.token) diff --git a/src/pluralkit/bot/channel_logger.py b/src/pluralkit/bot/channel_logger.py deleted file mode 100644 index 56aea9a1..00000000 --- a/src/pluralkit/bot/channel_logger.py +++ /dev/null @@ -1,104 +0,0 @@ -import discord -import logging -from datetime import datetime - -from pluralkit import db - - -def embed_set_author_name(embed: discord.Embed, channel_name: str, member_name: str, system_name: str, avatar_url: str): - name = "#{}: {}".format(channel_name, member_name) - if system_name: - name += " ({})".format(system_name) - - embed.set_author(name=name, icon_url=avatar_url or discord.Embed.Empty) - - -class ChannelLogger: - def __init__(self, client: discord.Client): - self.logger = logging.getLogger("pluralkit.bot.channel_logger") - self.client = client - - async def get_log_channel(self, conn, server_id: int): - server_info = await db.get_server_info(conn, server_id) - - if not server_info: - return None - - log_channel = server_info["log_channel"] - - if not log_channel: - return None - - return self.client.get_channel(log_channel) - - async def send_to_log_channel(self, log_channel: discord.TextChannel, embed: discord.Embed, text: str = None): - try: - await log_channel.send(content=text, embed=embed) - except discord.Forbidden: - # TODO: spew big error - self.logger.warning( - "Did not have permission to send message to logging channel (server={}, channel={})".format( - log_channel.guild.id, log_channel.id)) - - async def log_message_proxied(self, conn, - server_id: int, - channel_name: str, - channel_id: int, - sender_name: str, - sender_disc: int, - sender_id: int, - member_name: str, - member_hid: str, - member_avatar_url: str, - system_name: str, - system_hid: str, - message_text: str, - message_image: str, - message_timestamp: datetime, - message_id: int): - log_channel = await self.get_log_channel(conn, server_id) - if not log_channel: - return - - message_link = "https://discordapp.com/channels/{}/{}/{}".format(server_id, channel_id, message_id) - - embed = discord.Embed() - embed.colour = discord.Colour.blue() - embed.description = message_text - embed.timestamp = message_timestamp - - embed_set_author_name(embed, channel_name, member_name, system_name, member_avatar_url) - embed.set_footer( - text="System ID: {} | Member ID: {} | Sender: {}#{} ({}) | Message ID: {}".format(system_hid, member_hid, - sender_name, sender_disc, - sender_id, message_id)) - - if message_image: - embed.set_thumbnail(url=message_image) - - await self.send_to_log_channel(log_channel, embed, message_link) - - async def log_message_deleted(self, conn, - server_id: int, - channel_name: str, - member_name: str, - member_hid: str, - member_avatar_url: str, - system_name: str, - system_hid: str, - message_text: str, - message_id: int): - log_channel = await self.get_log_channel(conn, server_id) - if not log_channel: - return - - embed = discord.Embed() - embed.colour = discord.Colour.dark_red() - embed.description = message_text or "*(unknown, message deleted by moderator)*" - embed.timestamp = datetime.utcnow() - - embed_set_author_name(embed, channel_name, member_name, system_name, member_avatar_url) - embed.set_footer( - text="System ID: {} | Member ID: {} | Message ID: {}".format(system_hid, member_hid, message_id)) - - await self.send_to_log_channel(log_channel, embed) diff --git a/src/pluralkit/bot/commands/__init__.py b/src/pluralkit/bot/commands/__init__.py deleted file mode 100644 index fef96ae7..00000000 --- a/src/pluralkit/bot/commands/__init__.py +++ /dev/null @@ -1,243 +0,0 @@ -import asyncio -from datetime import datetime - -import discord -import re -from typing import Tuple, Optional, Union - -from pluralkit import db -from pluralkit.bot import embeds, utils -from pluralkit.errors import PluralKitError -from pluralkit.member import Member -from pluralkit.system import System - - -def next_arg(arg_string: str) -> Tuple[str, Optional[str]]: - # A basic quoted-arg parser - - for quote in "“‟”": - arg_string = arg_string.replace(quote, "\"") - - if arg_string.startswith("\""): - end_quote = arg_string[1:].find("\"") + 1 - if end_quote > 0: - return arg_string[1:end_quote], arg_string[end_quote + 1:].strip() - else: - return arg_string[1:], None - - next_space = arg_string.find(" ") - if next_space >= 0: - return arg_string[:next_space].strip(), arg_string[next_space:].strip() - else: - return arg_string.strip(), None - - -class CommandError(Exception): - def __init__(self, text: str, help: Tuple[str, str] = None): - self.text = text - self.help = help - - def format(self): - return "\u274c " + self.text, embeds.error("", self.help) if self.help else None - - -class CommandContext: - client: discord.Client - message: discord.Message - - def __init__(self, client: discord.Client, message: discord.Message, conn, args: str, system: Optional[System]): - self.client = client - self.message = message - self.conn = conn - self.args = args - self._system = system - - async def get_system(self) -> Optional[System]: - return self._system - - async def ensure_system(self) -> System: - system = await self.get_system() - - if not system: - raise CommandError("No system registered to this account. Use `pk;system new` to register one.") - - return system - - def has_next(self) -> bool: - return bool(self.args) - - def format_time(self, dt: datetime): - if self._system: - return self._system.format_time(dt) - return dt.isoformat(sep=" ", timespec="seconds") + " UTC" - - def pop_str(self, error: CommandError = None) -> Optional[str]: - if not self.args: - if error: - raise error - return None - - popped, self.args = next_arg(self.args) - return popped - - def peek_str(self) -> Optional[str]: - if not self.args: - return None - popped, _ = next_arg(self.args) - return popped - - def match(self, next) -> bool: - peeked = self.peek_str() - if peeked and peeked.lower() == next.lower(): - self.pop_str() - return True - return False - - async def pop_system(self, error: CommandError = None) -> System: - name = self.pop_str(error) - system = await utils.get_system_fuzzy(self.conn, self.client, name) - - if not system: - raise CommandError("Unable to find system '{}'.".format(name)) - - return system - - async def pop_member(self, error: CommandError = None, system_only: bool = True) -> Member: - name = self.pop_str(error) - - if system_only: - system = await self.ensure_system() - else: - system = await self.get_system() - - member = await utils.get_member_fuzzy(self.conn, system.id if system else None, name, system_only) - if not member: - raise CommandError("Unable to find member '{}'{}.".format(name, " in your system" if system_only else "")) - - return member - - def remaining(self): - return self.args - - async def reply(self, content=None, embed=None): - return await self.message.channel.send(content=content, embed=embed) - - async def reply_ok(self, content=None, embed=None): - return await self.reply(content="\u2705 {}".format(content or ""), embed=embed) - - async def reply_warn(self, content=None, embed=None): - return await self.reply(content="\u26a0 {}".format(content or ""), embed=embed) - - async def reply_ok_dm(self, content: str): - if isinstance(self.message.channel, discord.DMChannel): - await self.reply_ok(content="\u2705 {}".format(content or "")) - else: - await self.message.author.send(content="\u2705 {}".format(content or "")) - await self.reply_ok("DM'd!") - - async def confirm_react(self, user: Union[discord.Member, discord.User], message: discord.Message): - await message.add_reaction("\u2705") # Checkmark - await message.add_reaction("\u274c") # Red X - - try: - reaction, _ = await self.client.wait_for("reaction_add", - check=lambda r, u: u.id == user.id and r.emoji in ["\u2705", - "\u274c"], - timeout=60.0 * 5) - return reaction.emoji == "\u2705" - except asyncio.TimeoutError: - raise CommandError("Timed out - try again.") - - async def confirm_text(self, user: discord.Member, channel: discord.TextChannel, confirm_text: str, message: str): - await self.reply(message) - - try: - message = await self.client.wait_for("message", - check=lambda m: m.channel.id == channel.id and m.author.id == user.id, - timeout=60.0 * 5) - return message.content.lower() == confirm_text.lower() - except asyncio.TimeoutError: - raise CommandError("Timed out - try again.") - - -import pluralkit.bot.commands.api_commands -import pluralkit.bot.commands.import_commands -import pluralkit.bot.commands.member_commands -import pluralkit.bot.commands.message_commands -import pluralkit.bot.commands.misc_commands -import pluralkit.bot.commands.mod_commands -import pluralkit.bot.commands.switch_commands -import pluralkit.bot.commands.system_commands - - -async def command_root(ctx: CommandContext): - if ctx.match("system") or ctx.match("s"): - await system_commands.system_root(ctx) - elif ctx.match("member") or ctx.match("m"): - await member_commands.member_root(ctx) - elif ctx.match("link"): - await system_commands.account_link(ctx) - elif ctx.match("unlink"): - await system_commands.account_unlink(ctx) - elif ctx.match("message"): - await message_commands.message_info(ctx) - elif ctx.match("log"): - await mod_commands.set_log(ctx) - elif ctx.match("invite"): - await misc_commands.invite_link(ctx) - elif ctx.match("export"): - await misc_commands.export(ctx) - elif ctx.match("switch") or ctx.match("sw"): - await switch_commands.switch_root(ctx) - elif ctx.match("token"): - await api_commands.token_root(ctx) - elif ctx.match("import"): - await import_commands.import_root(ctx) - elif ctx.match("help"): - await misc_commands.help_root(ctx) - elif ctx.match("tell"): - await misc_commands.tell(ctx) - elif ctx.match("fire"): - await misc_commands.pkfire(ctx) - elif ctx.match("thunder"): - await misc_commands.pkthunder(ctx) - elif ctx.match("freeze"): - await misc_commands.pkfreeze(ctx) - elif ctx.match("starstorm"): - await misc_commands.pkstarstorm(ctx) - elif ctx.match("commands"): - await misc_commands.command_list(ctx) - else: - raise CommandError("Unknown command {}. For a list of commands, type `pk;commands`.".format(ctx.pop_str())) - - -async def run_command(ctx: CommandContext, func): - # lol nested try - try: - try: - await func(ctx) - except PluralKitError as e: - raise CommandError(e.message, e.help_page) - except CommandError as e: - content, embed = e.format() - await ctx.reply(content=content, embed=embed) - - -async def command_dispatch(client: discord.Client, message: discord.Message, conn) -> bool: - prefix = "^(pk(;|!)|<@{}> )".format(client.user.id) - regex = re.compile(prefix, re.IGNORECASE) - - cmd = message.content - match = regex.match(cmd) - if match: - remaining_string = cmd[match.span()[1]:].strip() - ctx = CommandContext( - client=client, - message=message, - conn=conn, - args=remaining_string, - system=await System.get_by_account(conn, message.author.id) - ) - await run_command(ctx, command_root) - return True - return False diff --git a/src/pluralkit/bot/commands/api_commands.py b/src/pluralkit/bot/commands/api_commands.py deleted file mode 100644 index d17d8163..00000000 --- a/src/pluralkit/bot/commands/api_commands.py +++ /dev/null @@ -1,35 +0,0 @@ -from pluralkit.bot.commands import CommandContext - -disclaimer = "\u26A0 Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure. If it leaks or you need a new one, you can invalidate this one with `pk;token refresh`." - - -async def token_root(ctx: CommandContext): - if ctx.match("refresh") or ctx.match("expire") or ctx.match("invalidate") or ctx.match("update"): - await token_refresh(ctx) - else: - await token_get(ctx) - - -async def token_get(ctx: CommandContext): - system = await ctx.ensure_system() - - if system.token: - token = system.token - else: - token = await system.refresh_token(ctx.conn) - - token_message = "{}\n\u2705 Here's your API token:".format(disclaimer) - if token: - await ctx.reply_ok("DM'd!") - await ctx.message.author.send(token_message) - await ctx.message.author.send(token) - return - -async def token_refresh(ctx: CommandContext): - system = await ctx.ensure_system() - - token = await system.refresh_token(ctx.conn) - token_message = "Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\n{}\n\u2705 Here's your new API token:".format(disclaimer) - if token: - await ctx.message.author.send(token_message) - await ctx.message.author.send(token) diff --git a/src/pluralkit/bot/commands/import_commands.py b/src/pluralkit/bot/commands/import_commands.py deleted file mode 100644 index 96083435..00000000 --- a/src/pluralkit/bot/commands/import_commands.py +++ /dev/null @@ -1,49 +0,0 @@ -import aiohttp -import asyncio -import io -import json -import os -from datetime import datetime - -from pluralkit.errors import TupperboxImportError -from pluralkit.bot.commands import * - -async def import_root(ctx: CommandContext): - # Only one import method rn, so why not default to Tupperbox? - await import_tupperbox(ctx) - - -async def import_tupperbox(ctx: CommandContext): - await ctx.reply("To import from Tupperbox, reply to this message with a `tuppers.json` file imported from Tupperbox.\n\nTo obtain such a file, type `tul!export` (or your server's equivalent).") - - def predicate(msg): - if msg.author.id != ctx.message.author.id: - return False - if msg.attachments: - if msg.attachments[0].filename.endswith(".json"): - return True - return False - - try: - message = await ctx.client.wait_for("message", check=predicate, timeout=60*5) - except asyncio.TimeoutError: - raise CommandError("Timed out. Try running `pk;import` again.") - - s = io.BytesIO() - await message.attachments[0].save(s, use_cached=False) - data = json.load(s) - - system = await ctx.get_system() - if not system: - system = await System.create_system(ctx.conn, account_id=ctx.message.author.id) - - result = await system.import_from_tupperbox(ctx.conn, data) - tag_note = "" - if len(result.tags) > 1: - tag_note = "\n\nPluralKit's tags work on a per-system basis. Since your Tupperbox members have more than one unique tag, PluralKit has not imported the tags. Set your system tag manually with `pk;system tag <tag>`." - - await ctx.reply_ok("Updated {} member{}, created {} member{}. Type `pk;system list` to check!{}".format( - len(result.updated), "s" if len(result.updated) != 1 else "", - len(result.created), "s" if len(result.created) != 1 else "", - tag_note - )) diff --git a/src/pluralkit/bot/commands/member_commands.py b/src/pluralkit/bot/commands/member_commands.py deleted file mode 100644 index 5f1f7a23..00000000 --- a/src/pluralkit/bot/commands/member_commands.py +++ /dev/null @@ -1,192 +0,0 @@ -import pluralkit.bot.embeds -from pluralkit.bot import help -from pluralkit.bot.commands import * -from pluralkit.errors import PluralKitError - - -async def member_root(ctx: CommandContext): - if ctx.match("new") or ctx.match("create") or ctx.match("add") or ctx.match("register"): - await new_member(ctx) - elif ctx.match("set"): - await member_set(ctx) - # TODO "pk;member list" - elif not ctx.has_next(): - raise CommandError("Must pass a subcommand. For a list of subcommands, type `pk;help member`.") - else: - await specific_member_root(ctx) - - -async def specific_member_root(ctx: CommandContext): - member = await ctx.pop_member(system_only=False) - - if ctx.has_next(): - # Following commands operate on members only in the caller's own system - # error if not, to make sure you can't destructively edit someone else's member - system = await ctx.ensure_system() - if not member.system == system.id: - raise CommandError("Member must be in your own system.") - - if ctx.match("name") or ctx.match("rename"): - await member_name(ctx, member) - elif ctx.match("description") or ctx.match("desc"): - await member_description(ctx, member) - elif ctx.match("avatar") or ctx.match("icon"): - await member_avatar(ctx, member) - elif ctx.match("proxy") or ctx.match("tags"): - await member_proxy(ctx, member) - elif ctx.match("pronouns") or ctx.match("pronoun"): - await member_pronouns(ctx, member) - elif ctx.match("color") or ctx.match("colour"): - await member_color(ctx, member) - elif ctx.match("birthday") or ctx.match("birthdate") or ctx.match("bday"): - await member_birthdate(ctx, member) - elif ctx.match("delete") or ctx.match("remove") or ctx.match("destroy") or ctx.match("erase"): - await member_delete(ctx, member) - else: - raise CommandError( - "Unknown subcommand {}. For a list of all commands, type `pk;help member`".format(ctx.pop_str())) - else: - # Basic lookup - await member_info(ctx, member) - - -async def member_info(ctx: CommandContext, member: Member): - await ctx.reply(embed=await pluralkit.bot.embeds.member_card(ctx.conn, member)) - - -async def new_member(ctx: CommandContext): - system = await ctx.ensure_system() - if not ctx.has_next(): - raise CommandError("You must pass a name for the new member.") - - new_name = ctx.remaining() - - existing_member = await Member.get_member_by_name(ctx.conn, system.id, new_name) - if existing_member: - msg = await ctx.reply_warn( - "There is already a member with this name, with the ID `{}`. Do you want to create a duplicate member anyway?".format( - existing_member.hid)) - if not await ctx.confirm_react(ctx.message.author, msg): - raise CommandError("Member creation cancelled.") - - try: - member = await system.create_member(ctx.conn, new_name) - except PluralKitError as e: - raise CommandError(e.message) - - await ctx.reply_ok( - "Member \"{}\" (`{}`) registered! Type `pk;help member` for a list of commands to edit this member.".format(new_name, member.hid)) - - -async def member_set(ctx: CommandContext): - raise CommandError( - "`pk;member set` has been retired. Please use the new member modifying commands. Type `pk;help member` for a list.") - - -async def member_name(ctx: CommandContext, member: Member): - system = await ctx.ensure_system() - new_name = ctx.pop_str(CommandError("You must pass a new member name.")) - - # Warn if there's a member by the same name already - existing_member = await Member.get_member_by_name(ctx.conn, system.id, new_name) - if existing_member and existing_member.id != member.id: - msg = await ctx.reply_warn( - "There is already another member with this name, with the ID `{}`. Do you want to rename this member anyway? This will result in two members with the same name.".format( - existing_member.hid)) - if not await ctx.confirm_react(ctx.message.author, msg): - raise CommandError("Member renaming cancelled.") - - await member.set_name(ctx.conn, new_name) - await ctx.reply_ok("Member name updated.") - - if len(new_name) < 2 and not system.tag: - await ctx.reply_warn( - "This member's new name is under 2 characters, and thus cannot be proxied. To prevent this, use a longer member name, or add a system tag.") - elif len(new_name) > 32: - exceeds_by = len(new_name) - 32 - await ctx.reply_warn( - "This member's new name is longer than 32 characters, and thus cannot be proxied. To prevent this, shorten the member name by {} characters.".format( - exceeds_by)) - elif len(new_name) > system.get_member_name_limit(): - exceeds_by = len(new_name) - system.get_member_name_limit() - await ctx.reply_warn( - "This member's new name, when combined with the system tag `{}`, is longer than 32 characters, and thus cannot be proxied. To prevent this, shorten the name or system tag by at least {} characters.".format( - system.tag, exceeds_by)) - - -async def member_description(ctx: CommandContext, member: Member): - new_description = ctx.remaining() or None - - await member.set_description(ctx.conn, new_description) - await ctx.reply_ok("Member description {}.".format("updated" if new_description else "cleared")) - - -async def member_avatar(ctx: CommandContext, member: Member): - new_avatar_url = ctx.remaining() or None - - if new_avatar_url: - user = await utils.parse_mention(ctx.client, new_avatar_url) - if user: - new_avatar_url = user.avatar_url_as(format="png") - - await member.set_avatar(ctx.conn, new_avatar_url) - await ctx.reply_ok("Member avatar {}.".format("updated" if new_avatar_url else "cleared")) - - -async def member_color(ctx: CommandContext, member: Member): - new_color = ctx.remaining() or None - - await member.set_color(ctx.conn, new_color) - await ctx.reply_ok("Member color {}.".format("updated" if new_color else "cleared")) - - -async def member_pronouns(ctx: CommandContext, member: Member): - new_pronouns = ctx.remaining() or None - - await member.set_pronouns(ctx.conn, new_pronouns) - await ctx.reply_ok("Member pronouns {}.".format("updated" if new_pronouns else "cleared")) - - -async def member_birthdate(ctx: CommandContext, member: Member): - new_birthdate = ctx.remaining() or None - - await member.set_birthdate(ctx.conn, new_birthdate) - await ctx.reply_ok("Member birthdate {}.".format("updated" if new_birthdate else "cleared")) - - -async def member_proxy(ctx: CommandContext, member: Member): - if not ctx.has_next(): - prefix, suffix = None, None - else: - # Sanity checking - example = ctx.remaining() - if "text" not in example: - raise CommandError("Example proxy message must contain the string 'text'. For help, type `pk;help proxy`.") - - if example.count("text") != 1: - raise CommandError("Example proxy message must contain the string 'text' exactly once. For help, type `pk;help proxy`.") - - # Extract prefix and suffix - prefix = example[:example.index("text")].strip() - suffix = example[example.index("text") + 4:].strip() - - # DB stores empty strings as None, make that work - if not prefix: - prefix = None - if not suffix: - suffix = None - - async with ctx.conn.transaction(): - await member.set_proxy_tags(ctx.conn, prefix, suffix) - await ctx.reply_ok( - "Proxy settings updated." if prefix or suffix else "Proxy settings cleared. If you meant to set your proxy tags, type `pk;help proxy` for help.") - - -async def member_delete(ctx: CommandContext, member: Member): - delete_confirm_msg = "Are you sure you want to delete {}? If so, reply to this message with the member's ID (`{}`).".format( - member.name, member.hid) - if not await ctx.confirm_text(ctx.message.author, ctx.message.channel, member.hid, delete_confirm_msg): - raise CommandError("Member deletion cancelled.") - - await member.delete(ctx.conn) - await ctx.reply_ok("Member deleted.") diff --git a/src/pluralkit/bot/commands/message_commands.py b/src/pluralkit/bot/commands/message_commands.py deleted file mode 100644 index 2d1e4614..00000000 --- a/src/pluralkit/bot/commands/message_commands.py +++ /dev/null @@ -1,18 +0,0 @@ -from pluralkit.bot.commands import * - - -async def message_info(ctx: CommandContext): - mid_str = ctx.pop_str(CommandError("You must pass a message ID.")) - - try: - mid = int(mid_str) - except ValueError: - raise CommandError("You must pass a valid number as a message ID.") - - # Find the message in the DB - message = await db.get_message(ctx.conn, mid) - if not message: - raise CommandError( - "Message with ID '{}' not found. Are you sure it's a message proxied by PluralKit?".format(mid)) - - await ctx.reply(embed=await embeds.message_card(ctx.client, message)) diff --git a/src/pluralkit/bot/commands/misc_commands.py b/src/pluralkit/bot/commands/misc_commands.py deleted file mode 100644 index 2b6caa7f..00000000 --- a/src/pluralkit/bot/commands/misc_commands.py +++ /dev/null @@ -1,191 +0,0 @@ -import io -import json -import os -from discord.utils import oauth_url - -from pluralkit.bot import help -from pluralkit.bot.commands import * -from pluralkit.bot.embeds import help_footer_embed - -prefix = "pk;" # TODO: configurable - -def make_footer_embed(): - embed = discord.Embed() - embed.set_footer(text=help.helpfile["footer"]) - return embed - -def make_command_embed(command): - embed = make_footer_embed() - embed.title = prefix + command["usage"] - embed.description = (command["description"] + "\n" + command.get("longdesc", "")).strip() - if "aliases" in command: - embed.add_field(name="Aliases" if len(command["aliases"]) > 1 else "Alias", value="\n".join([prefix + cmd for cmd in command["aliases"]]), inline=False) - embed.add_field(name="Usage", value=prefix + command["usage"], inline=False) - if "examples" in command: - embed.add_field(name="Examples" if len(command["examples"]) > 1 else "Example", value="\n".join([prefix + cmd for cmd in command["examples"]]), inline=False) - if "subcommands" in command: - embed.add_field(name="Subcommands", value="\n".join([command["name"] + " " + sc["name"] for sc in command["subcommands"]]), inline=False) - return embed - -def find_command(command_list, name): - for command in command_list: - if command["name"].lower().strip() == name.lower().strip(): - return command - -async def help_root(ctx: CommandContext): - for page_name, page_content in help.helpfile["pages"].items(): - if ctx.match(page_name): - return await help_page(ctx, page_content) - - if not ctx.has_next(): - return await help_page(ctx, help.helpfile["pages"]["root"]) - - return await help_command(ctx, ctx.remaining()) - -async def help_page(ctx, sections): - msg = "" - for section in sections: - msg += "__**{}**__\n{}\n\n".format(section["name"], section["content"]) - - return await ctx.reply(content=msg, embed=make_footer_embed()) - -async def help_command(ctx, command_name): - name_parts = command_name.replace(prefix, "").split(" ") - command = find_command(help.helpfile["commands"], name_parts[0]) - name_parts = name_parts[1:] - if not command: - raise CommandError("Could not find command '{}'.".format(command_name)) - while len(name_parts) > 0: - found_command = find_command(command["subcommands"], name_parts[0]) - if not found_command: - break - command = found_command - name_parts = name_parts[1:] - - return await ctx.reply(embed=make_command_embed(command)) - -async def command_list(ctx): - cmds = [] - - categories = {} - def make_command_list(lst): - for cmd in lst: - if not cmd["category"] in categories: - categories[cmd["category"]] = [] - categories[cmd["category"]].append("**{}{}** - {}".format(prefix, cmd["usage"], cmd["description"])) - if "subcommands" in cmd: - make_command_list(cmd["subcommands"]) - make_command_list(help.helpfile["commands"]) - - embed = discord.Embed() - embed.title = "PluralKit Commands" - embed.description = "Type `pk;help <command>` for more information." - for cat_name, cat_cmds in categories.items(): - embed.add_field(name=cat_name, value="\n".join(cat_cmds)) - await ctx.reply(embed=embed) - - -async def invite_link(ctx: CommandContext): - client_id = (await ctx.client.application_info()).id - - permissions = discord.Permissions() - - # So the bot can actually add the webhooks it needs to do the proxy functionality - permissions.manage_webhooks = True - - # So the bot can respond with status, error, and success messages - permissions.send_messages = True - - # So the bot can delete channels - permissions.manage_messages = True - - # So the bot can respond with extended embeds, ex. member cards - permissions.embed_links = True - - # So the bot can send images too - permissions.attach_files = True - - # (unsure if it needs this, actually, might be necessary for message lookup) - permissions.read_message_history = True - - # So the bot can add reactions for confirm/deny prompts - permissions.add_reactions = True - - url = oauth_url(client_id, permissions) - await ctx.reply_ok("Use this link to add PluralKit to your server: {}".format(url)) - - -async def export(ctx: CommandContext): - working_msg = await ctx.message.channel.send("Working...") - - system = await ctx.ensure_system() - - members = await system.get_members(ctx.conn) - accounts = await system.get_linked_account_ids(ctx.conn) - switches = await system.get_switches(ctx.conn, 999999) - - data = { - "name": system.name, - "id": system.hid, - "description": system.description, - "tag": system.tag, - "avatar_url": system.avatar_url, - "created": system.created.isoformat(), - "members": [ - { - "name": member.name, - "id": member.hid, - "color": member.color, - "avatar_url": member.avatar_url, - "birthday": member.birthday.isoformat() if member.birthday else None, - "pronouns": member.pronouns, - "description": member.description, - "prefix": member.prefix, - "suffix": member.suffix, - "created": member.created.isoformat(), - "message_count": await member.message_count(ctx.conn) - } for member in members - ], - "accounts": [str(uid) for uid in accounts], - "switches": [ - { - "timestamp": switch.timestamp.isoformat(), - "members": [member.hid for member in await switch.fetch_members(ctx.conn)] - } for switch in switches - ] # TODO: messages - } - - await working_msg.delete() - - f = io.BytesIO(json.dumps(data).encode("utf-8")) - await ctx.reply_ok("DM'd!") - await ctx.message.author.send(content="Here you go!", file=discord.File(fp=f, filename="pluralkit_system.json")) - - -async def tell(ctx: CommandContext): - # Dev command only - # This is used to tell members of servers I'm not in when something is broken so they can contact me with debug info - if ctx.message.author.id != 102083498529026048: - # Just silently fail, not really a public use command - return - - channel = ctx.pop_str() - message = ctx.remaining() - - # lol error handling - await ctx.client.get_channel(int(channel)).send(content="[dev message] " + message) - await ctx.reply_ok("Sent!") - - -# Easter eggs lmao because why not -async def pkfire(ctx: CommandContext): - await ctx.message.channel.send("*A giant lightning bolt promptly erupts into a pillar of fire as it hits your opponent.*") - -async def pkthunder(ctx: CommandContext): - await ctx.message.channel.send("*A giant ball of lightning is conjured and fired directly at your opponent, vanquishing them.*") - -async def pkfreeze(ctx: CommandContext): - await ctx.message.channel.send("*A giant crystal ball of ice is charged and hurled toward your opponent, bursting open and freezing them solid on contact.*") - -async def pkstarstorm(ctx: CommandContext): - await ctx.message.channel.send("*Vibrant colours burst forth from the sky as meteors rain down upon your opponent.*") diff --git a/src/pluralkit/bot/commands/mod_commands.py b/src/pluralkit/bot/commands/mod_commands.py deleted file mode 100644 index 47c05fcc..00000000 --- a/src/pluralkit/bot/commands/mod_commands.py +++ /dev/null @@ -1,21 +0,0 @@ -from pluralkit.bot.commands import * - - -async def set_log(ctx: CommandContext): - if not ctx.message.author.guild_permissions.administrator: - raise CommandError("You must be a server administrator to use this command.") - - server = ctx.message.guild - if not server: - raise CommandError("This command can not be run in a DM.") - - if not ctx.has_next(): - channel_id = None - else: - channel = utils.parse_channel_mention(ctx.pop_str(), server=server) - if not channel: - raise CommandError("Channel not found.") - channel_id = channel.id - - await db.update_server(ctx.conn, server.id, logging_channel_id=channel_id) - await ctx.reply_ok("Updated logging channel." if channel_id else "Cleared logging channel.") diff --git a/src/pluralkit/bot/commands/switch_commands.py b/src/pluralkit/bot/commands/switch_commands.py deleted file mode 100644 index 0a4df74e..00000000 --- a/src/pluralkit/bot/commands/switch_commands.py +++ /dev/null @@ -1,156 +0,0 @@ -from datetime import datetime -from typing import List - -import dateparser -import pytz - -from pluralkit.bot.commands import * -from pluralkit.member import Member -from pluralkit.utils import display_relative - - -async def switch_root(ctx: CommandContext): - if not ctx.has_next(): - raise CommandError("You must use a subcommand. For a list of subcommands, type `pk;help member`.") - - if ctx.match("out"): - await switch_out(ctx) - elif ctx.match("move"): - await switch_move(ctx) - elif ctx.match("delete") or ctx.match("remove") or ctx.match("erase") or ctx.match("cancel"): - await switch_delete(ctx) - else: - await switch_member(ctx) - - -async def switch_member(ctx: CommandContext): - system = await ctx.ensure_system() - - if not ctx.has_next(): - raise CommandError("You must pass at least one member name or ID to register a switch to.") - - members: List[Member] = [] - while ctx.has_next(): - members.append(await ctx.pop_member()) - - # Log the switch - await system.add_switch(ctx.conn, members) - - if len(members) == 1: - await ctx.reply_ok("Switch registered. Current fronter is now {}.".format(members[0].name)) - else: - await ctx.reply_ok( - "Switch registered. Current fronters are now {}.".format(", ".join([m.name for m in members]))) - - -async def switch_out(ctx: CommandContext): - system = await ctx.ensure_system() - - switch = await system.get_latest_switch(ctx.conn) - if switch and not switch.members: - raise CommandError("There's already no one in front.") - - # Log it, and don't log any members - await system.add_switch(ctx.conn, []) - await ctx.reply_ok("Switch-out registered.") - - -async def switch_delete(ctx: CommandContext): - system = await ctx.ensure_system() - - last_two_switches = await system.get_switches(ctx.conn, 2) - if not last_two_switches: - raise CommandError("You do not have a logged switch to delete.") - - last_switch = last_two_switches[0] - next_last_switch = last_two_switches[1] if len(last_two_switches) > 1 else None - - last_switch_members = ", ".join([member.name for member in await last_switch.fetch_members(ctx.conn)]) - last_switch_time = display_relative(last_switch.timestamp) - - if next_last_switch: - next_last_switch_members = ", ".join([member.name for member in await next_last_switch.fetch_members(ctx.conn)]) - next_last_switch_time = display_relative(next_last_switch.timestamp) - msg = await ctx.reply_warn("This will delete the latest switch ({}, {} ago). The next latest switch is {} ({} ago). Is this okay?".format(last_switch_members, last_switch_time, next_last_switch_members, next_last_switch_time)) - else: - msg = await ctx.reply_warn("This will delete the latest switch ({}, {} ago). You have no other switches logged. Is this okay?".format(last_switch_members, last_switch_time)) - - if not await ctx.confirm_react(ctx.message.author, msg): - raise CommandError("Switch deletion cancelled.") - - await last_switch.delete(ctx.conn) - - if next_last_switch: - # lol block scope amirite - # but yeah this is fine - await ctx.reply_ok("Switch deleted. Next latest switch is now {} ({} ago).".format(next_last_switch_members, next_last_switch_time)) - else: - await ctx.reply_ok("Switch deleted. You now have no logged switches.") - - -async def switch_move(ctx: CommandContext): - system = await ctx.ensure_system() - if not ctx.has_next(): - raise CommandError("You must pass a time to move the switch to.") - - # Parse the time to move to - new_time = dateparser.parse(ctx.remaining(), languages=["en"], settings={ - # Tell it to default to the system's given time zone - # If no time zone was given *explicitly in the string* it'll return as naive - "TIMEZONE": system.ui_tz - }) - - if not new_time: - raise CommandError("'{}' can't be parsed as a valid time.".format(ctx.remaining())) - - tz = pytz.timezone(system.ui_tz) - # So we default to putting the system's time zone in the tzinfo - if not new_time.tzinfo: - new_time = tz.localize(new_time) - - # Now that we have a system-time datetime, convert this to UTC and make it naive since that's what we deal with - new_time = pytz.utc.normalize(new_time).replace(tzinfo=None) - - # Make sure the time isn't in the future - if new_time > datetime.utcnow(): - raise CommandError("Can't move switch to a time in the future.") - - # Make sure it all runs in a big transaction for atomicity - async with ctx.conn.transaction(): - # Get the last two switches to make sure the switch to move isn't before the second-last switch - last_two_switches = await system.get_switches(ctx.conn, 2) - if len(last_two_switches) == 0: - raise CommandError("There are no registered switches for this system.") - - last_switch = last_two_switches[0] - if len(last_two_switches) > 1: - second_last_switch = last_two_switches[1] - - if new_time < second_last_switch.timestamp: - time_str = display_relative(second_last_switch.timestamp) - raise CommandError( - "Can't move switch to before last switch time ({} ago), as it would cause conflicts.".format(time_str)) - - # Display the confirmation message w/ humanized times - last_fronters = await last_switch.fetch_members(ctx.conn) - - members = ", ".join([member.name for member in last_fronters]) or "nobody" - last_absolute = ctx.format_time(last_switch.timestamp) - last_relative = display_relative(last_switch.timestamp) - new_absolute = ctx.format_time(new_time) - new_relative = display_relative(new_time) - - # Confirm with user - switch_confirm_message = await ctx.reply( - "This will move the latest switch ({}) from {} ({} ago) to {} ({} ago). Is this OK?".format(members, - last_absolute, - last_relative, - new_absolute, - new_relative)) - - if not await ctx.confirm_react(ctx.message.author, switch_confirm_message): - raise CommandError("Switch move cancelled.") - - # Actually move the switch - await last_switch.move(ctx.conn, new_time) - await ctx.reply_ok("Switch moved.") diff --git a/src/pluralkit/bot/commands/system_commands.py b/src/pluralkit/bot/commands/system_commands.py deleted file mode 100644 index 0e50d754..00000000 --- a/src/pluralkit/bot/commands/system_commands.py +++ /dev/null @@ -1,443 +0,0 @@ -from datetime import datetime, timedelta - -import aiohttp -import dateparser -import humanize -import math -import timezonefinder -import pytz - -import pluralkit.bot.embeds -from pluralkit.bot.commands import * -from pluralkit.errors import ExistingSystemError, UnlinkingLastAccountError, AccountAlreadyLinkedError -from pluralkit.utils import display_relative - -# This needs to load from the timezone file so we're preloading this so we -# don't have to do it on every invocation -tzf = timezonefinder.TimezoneFinder() - -async def system_root(ctx: CommandContext): - # Commands that operate without a specified system (usually defaults to the executor's own system) - if ctx.match("name") or ctx.match("rename"): - await system_name(ctx) - elif ctx.match("description") or ctx.match("desc"): - await system_description(ctx) - elif ctx.match("avatar") or ctx.match("icon"): - await system_avatar(ctx) - elif ctx.match("tag"): - await system_tag(ctx) - elif ctx.match("new") or ctx.match("register") or ctx.match("create") or ctx.match("init"): - await system_new(ctx) - elif ctx.match("delete") or ctx.match("remove") or ctx.match("destroy") or ctx.match("erase"): - await system_delete(ctx) - elif ctx.match("front") or ctx.match("fronter") or ctx.match("fronters"): - await system_fronter(ctx, await ctx.ensure_system()) - elif ctx.match("fronthistory"): - await system_fronthistory(ctx, await ctx.ensure_system()) - elif ctx.match("frontpercent") or ctx.match("frontbreakdown") or ctx.match("frontpercentage"): - await system_frontpercent(ctx, await ctx.ensure_system()) - elif ctx.match("timezone") or ctx.match("tz"): - await system_timezone(ctx) - elif ctx.match("set"): - await system_set(ctx) - elif ctx.match("list") or ctx.match("members"): - await system_list(ctx, await ctx.ensure_system()) - elif not ctx.has_next(): - # (no argument, command ends here, default to showing own system) - await system_info(ctx, await ctx.ensure_system()) - else: - # If nothing matches, the next argument is likely a system name/ID, so delegate - # to the specific system root - await specified_system_root(ctx) - - -async def specified_system_root(ctx: CommandContext): - # Commands that operate on a specified system (ie. not necessarily the command executor's) - system_name = ctx.pop_str() - system = await utils.get_system_fuzzy(ctx.conn, ctx.client, system_name) - if not system: - raise CommandError( - "Unable to find system `{}`. If you meant to run a command, type `pk;help system` for a list of system commands.".format( - system_name)) - - if ctx.match("front") or ctx.match("fronter"): - await system_fronter(ctx, system) - elif ctx.match("fronthistory"): - await system_fronthistory(ctx, system) - elif ctx.match("frontpercent") or ctx.match("frontbreakdown") or ctx.match("frontpercentage"): - await system_frontpercent(ctx, system) - elif ctx.match("list") or ctx.match("members"): - await system_list(ctx, system) - else: - await system_info(ctx, system) - - -async def system_info(ctx: CommandContext, system: System): - this_system = await ctx.get_system() - await ctx.reply(embed=await pluralkit.bot.embeds.system_card(ctx.conn, ctx.client, system, this_system and this_system.id == system.id)) - - -async def system_new(ctx: CommandContext): - new_name = ctx.remaining() or None - - try: - await System.create_system(ctx.conn, ctx.message.author.id, new_name) - except ExistingSystemError as e: - raise CommandError(e.message) - - await ctx.reply_ok("System registered! To begin adding members, use `pk;member new <name>`.") - - -async def system_set(ctx: CommandContext): - raise CommandError( - "`pk;system set` has been retired. Please use the new system modifying commands. Type `pk;help system` for a list.") - - -async def system_name(ctx: CommandContext): - system = await ctx.ensure_system() - new_name = ctx.remaining() or None - - await system.set_name(ctx.conn, new_name) - await ctx.reply_ok("System name {}.".format("updated" if new_name else "cleared")) - - -async def system_description(ctx: CommandContext): - system = await ctx.ensure_system() - new_description = ctx.remaining() or None - - await system.set_description(ctx.conn, new_description) - await ctx.reply_ok("System description {}.".format("updated" if new_description else "cleared")) - - -async def system_timezone(ctx: CommandContext): - system = await ctx.ensure_system() - city_query = ctx.remaining() or None - - msg = await ctx.reply("\U0001F50D Searching '{}' (may take a while)...".format(city_query)) - - # Look up the city on Overpass (OpenStreetMap) - async with aiohttp.ClientSession() as sess: - # OverpassQL is weird, but this basically searches for every node of type city with name [input]. - async with sess.get("https://nominatim.openstreetmap.org/search?city=novosibirsk&format=json&limit=1", params={"city": city_query, "format": "json", "limit": "1"}) as r: - if r.status != 200: - raise CommandError("OSM Nominatim API returned error. Try again.") - data = await r.json() - - # If we didn't find a city, complain - if not data: - raise CommandError("City '{}' not found.".format(city_query)) - - # Take the lat/long given by Overpass and put it into timezonefinder - lat, lng = (float(data[0]["lat"]), float(data[0]["lon"])) - timezone_name = tzf.timezone_at(lng=lng, lat=lat) - - # Also delete the original searching message - await msg.delete() - - if not timezone_name: - raise CommandError("Time zone for city '{}' not found. This should never happen.".format(data[0]["display_name"])) - - # This should hopefully result in a valid time zone name - # (if not, something went wrong) - tz = await system.set_time_zone(ctx.conn, timezone_name) - offset = tz.utcoffset(datetime.utcnow()) - offset_str = "UTC{:+02d}:{:02d}".format(int(offset.total_seconds() // 3600), int(offset.total_seconds() // 60 % 60)) - - await ctx.reply_ok("System time zone set to {} ({}, {}).\n*Data from OpenStreetMap, queried using Nominatim.*".format(tz.tzname(datetime.utcnow()), offset_str, tz.zone)) - - -async def system_tag(ctx: CommandContext): - system = await ctx.ensure_system() - new_tag = ctx.remaining() or None - - await system.set_tag(ctx.conn, new_tag) - await ctx.reply_ok("System tag {}.".format("updated" if new_tag else "cleared")) - - # System class is immutable, update the tag so get_member_name_limit works - system = system._replace(tag=new_tag) - members = await system.get_members(ctx.conn) - - # Certain members might not be able to be proxied with this new tag, show a warning for those - members_exceeding = [member for member in members if - len(member.name) > system.get_member_name_limit()] - if members_exceeding: - member_names = ", ".join([member.name for member in members_exceeding]) - await ctx.reply_warn( - "Due to the length of this tag, the following members will not be able to be proxied: {}. Please use a shorter tag to prevent this.".format( - member_names)) - - # Edge case: members with name length 1 and no new tag - if not new_tag: - one_length_members = [member for member in members if len(member.name) == 1] - if one_length_members: - member_names = ", ".join([member.name for member in one_length_members]) - await ctx.reply_warn( - "Without a system tag, you will not be able to proxy members with a one-character name: {}. To prevent this, please add a system tag or lengthen their name.".format( - member_names)) - - -async def system_avatar(ctx: CommandContext): - system = await ctx.ensure_system() - new_avatar_url = ctx.remaining() or None - - if new_avatar_url: - user = await utils.parse_mention(ctx.client, new_avatar_url) - if user: - new_avatar_url = user.avatar_url_as(format="png") - - await system.set_avatar(ctx.conn, new_avatar_url) - await ctx.reply_ok("System avatar {}.".format("updated" if new_avatar_url else "cleared")) - - -async def account_link(ctx: CommandContext): - system = await ctx.ensure_system() - account_name = ctx.pop_str(CommandError( - "You must pass an account to link this system to. You can either use a \\@mention, or a raw account ID.")) - - # Do the sanity checking here too (despite it being done in System.link_account) - # Because we want it to be done before the confirmation dialog is shown - - # Find account to link - linkee = await utils.parse_mention(ctx.client, account_name) - if not linkee: - raise CommandError("Account `{}` not found.".format(account_name)) - - # Make sure account doesn't already have a system - account_system = await System.get_by_account(ctx.conn, linkee.id) - if account_system: - raise CommandError(AccountAlreadyLinkedError(account_system).message) - - msg = await ctx.reply( - "{}, please confirm the link by clicking the \u2705 reaction on this message.".format(linkee.mention)) - if not await ctx.confirm_react(linkee, msg): - raise CommandError("Account link cancelled.") - - await system.link_account(ctx.conn, linkee.id) - await ctx.reply_ok("Account linked to system.") - - -async def account_unlink(ctx: CommandContext): - system = await ctx.ensure_system() - - msg = await ctx.reply("Are you sure you want to unlink this account from your system?") - if not await ctx.confirm_react(ctx.message.author, msg): - raise CommandError("Account unlink cancelled.") - - try: - await system.unlink_account(ctx.conn, ctx.message.author.id) - except UnlinkingLastAccountError as e: - raise CommandError(e.message) - - await ctx.reply_ok("Account unlinked.") - - -async def system_fronter(ctx: CommandContext, system: System): - embed = await embeds.front_status(ctx, await system.get_latest_switch(ctx.conn)) - await ctx.reply(embed=embed) - - -async def system_fronthistory(ctx: CommandContext, system: System): - lines = [] - front_history = await pluralkit.utils.get_front_history(ctx.conn, system.id, count=10) - - if not front_history: - raise CommandError("You have no logged switches. Use `pk;switch´ to start logging.") - - for i, (timestamp, members) in enumerate(front_history): - # Special case when no one's fronting - if len(members) == 0: - name = "(no fronter)" - else: - name = ", ".join([member.name for member in members]) - - # Make proper date string - time_text = ctx.format_time(timestamp) - rel_text = display_relative(timestamp) - - delta_text = "" - if i > 0: - last_switch_time = front_history[i - 1][0] - delta_text = ", for {}".format(display_relative(timestamp - last_switch_time)) - lines.append("**{}** ({}, {} ago{})".format(name, time_text, rel_text, delta_text)) - - embed = embeds.status("\n".join(lines) or "(none)") - embed.title = "Past switches" - await ctx.reply(embed=embed) - - -async def system_delete(ctx: CommandContext): - system = await ctx.ensure_system() - - delete_confirm_msg = "Are you sure you want to delete your system? If so, reply to this message with the system's ID (`{}`).".format( - system.hid) - if not await ctx.confirm_text(ctx.message.author, ctx.message.channel, system.hid, delete_confirm_msg): - raise CommandError("System deletion cancelled.") - - await system.delete(ctx.conn) - await ctx.reply_ok("System deleted.") - - -async def system_frontpercent(ctx: CommandContext, system: System): - # Parse the time limit (will go this far back) - if ctx.remaining(): - before = dateparser.parse(ctx.remaining(), languages=["en"], settings={ - "TO_TIMEZONE": "UTC", - "RETURN_AS_TIMEZONE_AWARE": False - }) - - if not before: - raise CommandError("Could not parse '{}' as a valid time.".format(ctx.remaining())) - - # If time is in the future, just kinda discard - if before and before > datetime.utcnow(): - before = None - else: - before = datetime.utcnow() - timedelta(days=30) - - # Fetch list of switches - all_switches = await pluralkit.utils.get_front_history(ctx.conn, system.id, 99999) - if not all_switches: - raise CommandError("No switches registered to this system.") - - # Cull the switches *ending* before the limit, if given - # We'll need to find the first switch starting before the limit, then cut off every switch *before* that - if before: - for last_stamp, _ in all_switches: - if last_stamp < before: - break - - all_switches = [(stamp, members) for stamp, members in all_switches if stamp >= last_stamp] - - start_times = [stamp for stamp, _ in all_switches] - end_times = [datetime.utcnow()] + start_times - switch_members = [members for _, members in all_switches] - - # Gonna save a list of members by ID for future lookup too - members_by_id = {} - - # Using the ID as a key here because it's a simple number that can be hashed and used as a key - member_times = {} - for start_time, end_time, members in zip(start_times, end_times, switch_members): - # Cut off parts of the switch that occurs before the time limit (will only happen if this is the last switch) - if before and start_time < before: - start_time = before - - # Calculate length of the switch - switch_length = end_time - start_time - - def add_switch(id, length): - if id not in member_times: - member_times[id] = length - else: - member_times[id] += length - - for member in members: - # Add the switch length to the currently registered time for that member - add_switch(member.id, switch_length) - - # Also save the member in the ID map for future reference - members_by_id[member.id] = member - - # Also register a no-fronter switch with the key None - if not members: - add_switch(None, switch_length) - - # Find the total timespan of the range - span_start = max(start_times[-1], before) if before else start_times[-1] - total_time = datetime.utcnow() - span_start - - embed = embeds.status("") - for member_id, front_time in sorted(member_times.items(), key=lambda x: x[1], reverse=True): - member = members_by_id[member_id] if member_id else None - - # Calculate percent - fraction = front_time / total_time - percent = round(fraction * 100) - - embed.add_field(name=member.name if member else "(no fronter)", - value="{}% ({})".format(percent, humanize.naturaldelta(front_time))) - - embed.set_footer(text="Since {} ({} ago)".format(ctx.format_time(span_start), - display_relative(span_start))) - await ctx.reply(embed=embed) - -async def system_list(ctx: CommandContext, system: System): - # TODO: refactor this - - all_members = sorted(await system.get_members(ctx.conn), key=lambda m: m.name.lower()) - if ctx.match("full"): - page_size = 8 - if len(all_members) <= page_size: - # If we have less than 8 members, don't bother paginating - await ctx.reply(embed=embeds.member_list_full(system, all_members, 0, page_size)) - else: - current_page = 0 - msg: discord.Message = None - while True: - page_count = math.ceil(len(all_members) / page_size) - embed = embeds.member_list_full(system, all_members, current_page, page_size) - - # Add reactions for moving back and forth - if not msg: - msg = await ctx.reply(embed=embed) - await msg.add_reaction("\u2B05") - await msg.add_reaction("\u27A1") - else: - await msg.edit(embed=embed) - - def check(reaction, user): - return user.id == ctx.message.author.id and reaction.emoji in ["\u2B05", "\u27A1"] - - try: - reaction, _ = await ctx.client.wait_for("reaction_add", timeout=5*60, check=check) - except asyncio.TimeoutError: - return - - if reaction.emoji == "\u2B05": - current_page = (current_page - 1) % page_count - elif reaction.emoji == "\u27A1": - current_page = (current_page + 1) % page_count - - # If we can, remove the original reaction from the member - # Don't bother checking permission if we're in DMs (wouldn't work anyway) - if ctx.message.guild: - if ctx.message.channel.permissions_for(ctx.message.guild.get_member(ctx.client.user.id)).manage_messages: - await reaction.remove(ctx.message.author) - else: - - #Basically same code as above - #25 members at a time seems handy - page_size = 25 - if len(all_members) <= page_size: - # If we have less than 25 members, don't bother paginating - await ctx.reply(embed=embeds.member_list_short(system, all_members, 0, page_size)) - else: - current_page = 0 - msg: discord.Message = None - while True: - page_count = math.ceil(len(all_members) / page_size) - embed = embeds.member_list_short(system, all_members, current_page, page_size) - - if not msg: - msg = await ctx.reply(embed=embed) - await msg.add_reaction("\u2B05") - await msg.add_reaction("\u27A1") - else: - await msg.edit(embed=embed) - - def check(reaction, user): - return user.id == ctx.message.author.id and reaction.emoji in ["\u2B05", "\u27A1"] - - try: - reaction, _ = await ctx.client.wait_for("reaction_add", timeout=5*60, check=check) - except asyncio.TimeoutError: - return - - if reaction.emoji == "\u2B05": - current_page = (current_page - 1) % page_count - elif reaction.emoji == "\u27A1": - current_page = (current_page + 1) % page_count - - if ctx.message.guild: - if ctx.message.channel.permissions_for(ctx.message.guild.get_member(ctx.client.user.id)).manage_messages: - await reaction.remove(ctx.message.author) diff --git a/src/pluralkit/bot/embeds.py b/src/pluralkit/bot/embeds.py deleted file mode 100644 index a1570498..00000000 --- a/src/pluralkit/bot/embeds.py +++ /dev/null @@ -1,285 +0,0 @@ -import discord -import math -import humanize -from typing import Tuple, List - -from pluralkit import db -from pluralkit.bot.utils import escape -from pluralkit.member import Member -from pluralkit.switch import Switch -from pluralkit.system import System -from pluralkit.utils import get_fronters, display_relative - - -def truncate_field_name(s: str) -> str: - return s[:256] - - -def truncate_field_body(s: str) -> str: - if len(s) > 1024: - return s[:1024-3] + "..." - return s - - -def truncate_description(s: str) -> str: - return s[:2048] - - -def truncate_description_list(s: str) -> str: - if len(s) > 512: - return s[:512-45] + "..." - return s - - -def truncate_title(s: str) -> str: - return s[:256] - - -def success(text: str) -> discord.Embed: - embed = discord.Embed() - embed.description = truncate_description(text) - embed.colour = discord.Colour.green() - return embed - - -def error(text: str, help: Tuple[str, str] = None) -> discord.Embed: - embed = discord.Embed() - embed.description = truncate_description(text) - embed.colour = discord.Colour.dark_red() - - if help: - help_title, help_text = help - embed.add_field(name=truncate_field_name(help_title), value=truncate_field_body(help_text)) - - return embed - - -def status(text: str) -> discord.Embed: - embed = discord.Embed() - embed.description = truncate_description(text) - embed.colour = discord.Colour.blue() - return embed - - -def exception_log(message_content, author_name, author_discriminator, author_id, server_id, - channel_id) -> discord.Embed: - embed = discord.Embed() - embed.colour = discord.Colour.dark_red() - embed.title = truncate_title(message_content) - - embed.set_footer(text="Sender: {}#{} ({}) | Server: {} | Channel: {}".format( - author_name, author_discriminator, author_id, - server_id if server_id else "(DMs)", - channel_id - )) - return embed - - -async def system_card(conn, client: discord.Client, system: System, is_own_system: bool = True) -> discord.Embed: - card = discord.Embed() - card.colour = discord.Colour.blue() - - if system.name: - card.title = truncate_title(system.name) - - if system.avatar_url: - card.set_thumbnail(url=system.avatar_url) - - if system.tag: - card.add_field(name="Tag", value=truncate_field_body(system.tag)) - - fronters, switch_time = await get_fronters(conn, system.id) - if fronters: - names = ", ".join([member.name for member in fronters]) - fronter_val = "{} (for {})".format(names, humanize.naturaldelta(switch_time)) - card.add_field(name="Current fronter" if len(fronters) == 1 else "Current fronters", - value=truncate_field_body(fronter_val)) - - account_names = [] - for account_id in await system.get_linked_account_ids(conn): - try: - account = await client.get_user_info(account_id) - account_names.append("<@{}> ({}#{})".format(account_id, account.name, account.discriminator)) - except discord.NotFound: - account_names.append("(deleted account {})".format(account_id)) - - card.add_field(name="Linked accounts", value=truncate_field_body("\n".join(account_names))) - - if system.description: - card.add_field(name="Description", - value=truncate_field_body(system.description), inline=False) - - card.add_field(name="Members", value="*See `pk;system {0} list`for the short list, or `pk;system {0} list full` for the detailed list*".format(system.hid) if not is_own_system else "*See `pk;system list` for the short list, or `pk;system list full` for the detailed list*") - card.set_footer(text="System ID: {}".format(system.hid)) - return card - - -async def member_card(conn, member: Member) -> discord.Embed: - system = await member.fetch_system(conn) - - card = discord.Embed() - card.colour = discord.Colour.blue() - - name_and_system = member.name - if system.name: - name_and_system += " ({})".format(system.name) - - card.set_author(name=truncate_field_name(name_and_system), icon_url=member.avatar_url or discord.Embed.Empty) - if member.avatar_url: - card.set_thumbnail(url=member.avatar_url) - - if member.color: - card.colour = int(member.color, 16) - - if member.birthday: - card.add_field(name="Birthdate", value=member.birthday_string()) - - if member.pronouns: - card.add_field(name="Pronouns", value=truncate_field_body(member.pronouns)) - - message_count = await member.message_count(conn) - if message_count > 0: - card.add_field(name="Message Count", value=str(message_count), inline=True) - - if member.prefix or member.suffix: - prefix = member.prefix or "" - suffix = member.suffix or "" - card.add_field(name="Proxy Tags", - value=truncate_field_body("{}text{}".format(prefix, suffix))) - - if member.description: - card.add_field(name="Description", - value=truncate_field_body(member.description), inline=False) - - card.set_footer(text="System ID: {} | Member ID: {}".format(system.hid, member.hid)) - return card - - -async def front_status(ctx: "CommandContext", switch: Switch) -> discord.Embed: - if switch: - embed = status("") - fronter_names = [member.name for member in await switch.fetch_members(ctx.conn)] - - if len(fronter_names) == 0: - embed.add_field(name="Current fronter", value="(no fronter)") - elif len(fronter_names) == 1: - embed.add_field(name="Current fronter", value=truncate_field_body(fronter_names[0])) - else: - embed.add_field(name="Current fronters", value=truncate_field_body(", ".join(fronter_names))) - - if switch.timestamp: - embed.add_field(name="Since", - value="{} ({})".format(ctx.format_time(switch.timestamp), - display_relative(switch.timestamp))) - else: - embed = error("No switches logged.") - return embed - - -async def get_message_contents(client: discord.Client, channel_id: int, message_id: int): - channel = client.get_channel(channel_id) - if channel: - try: - original_message = await channel.get_message(message_id) - return original_message.content or None - except (discord.errors.Forbidden, discord.errors.NotFound): - pass - - return None - - -async def message_card(client: discord.Client, message: db.MessageInfo, include_pronouns: bool = False): - # Get the original sender of the messages - try: - original_sender = await client.get_user_info(message.sender) - except discord.NotFound: - # Account was since deleted - rare but we're handling it anyway - original_sender = None - - embed = discord.Embed() - embed.timestamp = discord.utils.snowflake_time(message.mid) - embed.colour = discord.Colour.blue() - - if message.system_name: - system_value = "{} (`{}`)".format(message.system_name, message.system_hid) - else: - system_value = "`{}`".format(message.system_hid) - embed.add_field(name="System", value=system_value) - - if include_pronouns and message.pronouns: - embed.add_field(name="Member", value="{} (`{}`)\n*(pronouns: **{}**)*".format(message.name, message.hid, message.pronouns)) - else: - embed.add_field(name="Member", value="{} (`{}`)".format(message.name, message.hid)) - - if original_sender: - sender_name = "<@{}> ({}#{})".format(message.sender, original_sender.name, original_sender.discriminator) - else: - sender_name = "(deleted account {})".format(message.sender) - - embed.add_field(name="Sent by", value=sender_name) - - message_content = await get_message_contents(client, message.channel, message.mid) - embed.description = message_content or "(unknown, message deleted)" - - embed.set_author(name=message.name, icon_url=message.avatar_url or discord.Embed.Empty) - return embed - - -def help_footer_embed() -> discord.Embed: - embed = discord.Embed() - embed.set_footer(text="By @Ske#6201 | GitHub: https://github.com/xSke/PluralKit/") - return embed - -# TODO: merge these somehow, they're very similar -def member_list_short(system: System, all_members: List[Member], current_page: int, page_size: int): - page_count = int(math.ceil(len(all_members) / page_size)) - - title = "" - if len(all_members) > page_size: - title += "[{}/{}] ".format(current_page + 1, page_count) - - if system.name: - title += "Members of {} (`{}`)".format(system.name, system.hid) - else: - title += "Members of `{}`".format(system.hid) - - embed = discord.Embed() - embed.title = title - - desc = "" - for member in all_members[current_page*page_size:current_page*page_size+page_size]: - if member.prefix or member.suffix: - desc += "[`{}`] **{}** *({}text{})*\n".format(member.hid, member.name, member.prefix or "", member.suffix or "") - else: - desc += "[`{}`] **{}**\n".format(member.hid, member.name) - embed.description = desc - return embed - -def member_list_full(system: System, all_members: List[Member], current_page: int, page_size: int): - page_count = int(math.ceil(len(all_members) / page_size)) - - title = "" - if len(all_members) > page_size: - title += "[{}/{}] ".format(current_page + 1, page_count) - - if system.name: - title += "Members of {} (`{}`)".format(system.name, system.hid) - else: - title += "Members of `{}`".format(system.hid) - - embed = discord.Embed() - embed.title = title - for member in all_members[current_page*page_size:current_page*page_size+page_size]: - member_description = "**ID**: {}\n".format(member.hid) - if member.birthday: - member_description += "**Birthday:** {}\n".format(member.birthday_string()) - if member.pronouns: - member_description += "**Pronouns:** {}\n".format(member.pronouns) - if member.description: - if len(member.description) > 512: - member_description += "\n" + truncate_description_list(member.description) + "\n" + "Type `pk;member {}` for full description.".format(member.hid) - else: - member_description += "\n" + member.description - - embed.add_field(name=member.name, value=truncate_field_body(member_description) or "\u200B", inline=False) - return embed diff --git a/src/pluralkit/bot/help.json b/src/pluralkit/bot/help.json deleted file mode 100644 index c6dc6bdd..00000000 --- a/src/pluralkit/bot/help.json +++ /dev/null @@ -1,336 +0,0 @@ -{ - "commands": [ - { - "name": "system", - "aliases": ["s"], - "usage": "system [id]", - "description": "Shows information about a system.", - "longdesc": "The given ID can either be a 5-character ID, a Discord account @mention, or a Discord account ID. Leave blank to show your own system.", - "examples": ["system", "system abcde", "system @Foo#1234", "system 102083498529026048"], - "category": "System", - "subcommands": [ - { - "name": "new", - "aliases": ["system register", "system create", "system init"], - "usage": "system new [name]", - "category": "System", - "description": "Creates a new system registered to your account." - }, - { - "name": "name", - "alises": ["system rename"], - "usage": "system name [name]", - "category": "System", - "description": "Changes the name of your system." - }, - { - "name": "description", - "aliases": ["system desc"], - "usage": "system description [description]", - "category": "System", - "description": "Changes the description of your system." - }, - { - "name": "avatar", - "aliases": ["system icon"], - "usage": "system avatar [avatar url]", - "category": "System", - "description": "Changes the avatar of your system.", - "longdesc": "**NB:** Avatar URLs must be a *direct* link to an image (ending in .jpg, .gif or .png), AND must be under the size of 1000x1000 (in both dimensions), AND must be smaller than 1 MB. If the avatar doesn't show up properly, it is likely one or more of these rules aren't followed. If you need somewhere to host an image, you can upload it to Discord or Imgur and copy the *direct* link from there.", - "examples": ["system avatar https://i.imgur.com/HmK2Wgo.png"] - }, - { - "name": "tag", - "usage": "system tag [tag]", - "category": "System", - "description": "Changes the system tag of your system.", - "longdesc": "The system tag is a snippet of text added to the end of your member's names when proxying. Many servers require the use of a system tag for identification. Leave blank to clear.\n\n**NB:** You may use standard Discord emojis, but server/Nitro emojis won't work.", - "examples": ["system tag |ABC", "system tag 💮", "system tag"] - }, - { - "name": "timezone", - "usage": "system timezone [location]", - "category": "System", - "description": "Changes the time zone of your system.", - "longdesc": "This affects all dates or times displayed in PluralKit. Leave blank to clear.\n\n**NB:** You need to specify a location (eg. the nearest major city to you). This allows PluralKit to dynamically adjust for time zone or DST changes.", - "examples": ["system timezone New York", "system timezone Wichita Falls", "system timezone"] - }, - { - "name": "delete", - "aliases": ["system remove", "system destroy", "system erase"], - "usage": "system delete", - "category": "System", - "description": "Deletes your system.", - "longdesc": "The command will ask for confirmation.\n\n**This is irreversible, and will delete all information associated with your system, members, proxied messages, and accounts.**" - }, - { - "name": "fronter", - "aliases": ["system front", "system fronters"], - "usage": "system [id] fronter", - "category": "System", - "description": "Shows the current fronter of a system." - }, - { - "name": "fronthistory", - "usage": "system [id] fronthistory", - "category": "System", - "description": "Shows the last 10 switches of a system." - }, - { - "name": "frontpercent", - "aliases": ["system frontbreakdown", "system frontpercentage"], - "usage": "system [id] fronthistory [timeframe]", - "category": "System", - "description": "Shows the aggregated front history of a system within a given time frame.", - "longdesc": "Percentages may add up to over 100% when multiple members cofront. Time frame will default to 1 month.", - "examples": ["system fronthistory 1 month", "system fronthistory 2 weeks", "system @Foo#1234 fronthistory 4 days"] - }, - { - "name": "list", - "aliases": ["system members"], - "usage": "system [id] list [full]", - "category": "System", - "description": "Shows a paginated list of a system's members. Add 'full' for more details.", - "examples": ["system list", "system list full", "system 102083498529026048 list"] - } - ] - }, - { - "name": "link", - "usage": "link <account>", - "category": "System", - "description": "Links this system to a different account.", - "longdesc": "This means you can manage the system from both accounts. The other account will need to verify the link by reacting to a message.", - "examples": ["link @Foo#1234", "link 102083498529026048"] - }, - { - "name": "unlink", - "usage": "unlink", - "category": "System", - "description": "Unlinks this account from its system.", - "longdesc": "You can't unlink the only account in a system." - }, - { - "name": "member", - "aliases": ["m"], - "usage": "member <name>", - "category": "Member", - "description": "Shows information about a member.", - "longdesc": "The given member name can either be the name of a member in your own system or a 5-character member ID (in any system).", - "examples": ["member John", "member abcde"], - "subcommands": [ - { - "name": "new", - "aliases": ["member add", "member create", "member register"], - "usage": "member new <name>", - "category": "Member", - "description": "Creates a new system member.", - "exmaples": ["member new Jack"] - }, - { - "name": "rename", - "usage": "member <name> rename <name>", - "category": "Member", - "description": "Changes the name of a member.", - "examples": ["member Jack rename Jill"] - }, - { - "name": "description", - "aliases": ["member desc"], - "usage": "member <name> description [description]", - "category": "Member", - "description": "Changes the description of a member.", - "examples": ["member Jack description Very cool guy."] - }, - { - "name": "avatar", - "aliases": ["member icon"], - "usage": "member <name> avatar [avatarurl]", - "category": "Member", - "description": "Changes the avatar of a member.", - "longdesc": "**NB:** Avatar URLs must be a *direct* link to an image (ending in .jpg, .gif or .png), AND must be under the size of 1000x1000 (in both dimensions), AND must be smaller than 1 MB. If the avatar doesn't show up properly, it is likely one or more of these rules aren't followed. If you need somewhere to host an image, you can upload it to Discord or Imgur and copy the *direct* link from there.", - "examples": ["member Jack avatar https://i.imgur.com/HmK2Wgo.png"] - }, - { - "name": "proxy", - "aliases": ["member tags"], - "usage": "member <name> proxy [tags]", - "category": "Member", - "description": "Changes the proxy tags of a member.", - "longdesc": "The proxy tags describe how to proxy this member through Discord. You must pass an \"example proxy\" of the word \"text\", ie. how you'd proxy the word \"text\". For example, if you want square brackets for this member, pass `[text]`. Emojis are allowed.", - "examples": ["member Jack proxy [text]", "member Jill proxy J:text", "member Jones proxy 🍒text"] - }, - { - "name": "pronouns", - "aliases": ["member pronoun"], - "usage": "member <name> pronouns [pronouns]", - "category": "Member", - "description": "Changes the pronouns of a member.", - "longdesc": "These will be displayed on their profile. This is a free text field, put whatever you'd like :)", - "examples": ["member Jack pronouns he/him", "member Jill pronouns she/her or they/them", "member Jones pronouns use whatever lol"] - }, - { - "name": "color", - "aliases": ["member colour"], - "usage": "member <name> color [color]", - "category": "Member", - "description": "Changes the color of a member.", - "longdesc": "This will displayed on their profile. Colors must be in hex format (eg. #ff0000).\n\n**NB:** Due to a Discord limitation, the colors don't affect proxied message names.", - "examples": ["member Jack color #ff0000", "member Jill color #abcdef"] - }, - { - "name": "birthday", - "aliases": ["member bday", "member birthdate"], - "usage": "member <name> birthday [birthday]", - "category": "Member", - "description": "Changes the birthday of a member.", - "longdesc": "This must be in YYYY-MM-DD format, or just MM-DD if you don't want to specify a year.", - "examples": ["member Jack birthday 1997-03-27", "member Jill birthday 2018-01-03", "member Jones birthday 12-21"] - }, - { - "name": "delete", - "aliases": ["member remove", "member destroy", "member erase"], - "usage": "member <name> delete", - "category": "Member", - "description": "Deletes a member.", - "longdesc": "This command will ask for confirmation.\n\n**This is irreversible, and will delete all data associated with this member.**" - } - ] - }, - { - "name": "switch", - "aliases": ["sw"], - "usage": "switch <member> [member...]", - "category": "Switching", - "description": "Registers a switch with the given members.", - "longdesc": "You may specify multiple members to indicate cofronting.", - "examples": ["switch Jack", "switch Jack Jill"], - "subcommands": [ - { - "name": "move", - "usage": "switch move <time>", - "category": "Switching", - "description": "Moves the latest switch back or forwards in time.", - "longdesc": "You can't move a switch into the future, and you can't move a switch further back than the second-latest switch (which would reorder front history).", - "examples": ["switch move 1 day ago", "switch move 4:30 pm"] - }, - { - "name": "delete", - "usage": "switch delete", - "category": "Switching", - "description": "Deletes the latest switch. Will ask for confirmation." - }, - { - "name": "out", - "usage": "switch out", - "category": "Switching", - "description": "Will register a 'switch-out' - a switch with no associated members." - } - ] - }, - { - "name": "log", - "usage": "log <channel>", - "category": "Utility", - "description": "Sets a channel to log all proxied messages.", - "longdesc": "This command is restricted to the server administrators (ie. users with the Administrator role).", - "examples": "log #pluralkit-log" - }, - { - "name": "message", - "usage": "message <messageid>", - "category": "Utility", - "description": "Looks up information about a message by its message ID.", - "longdesc": " You can obtain a message ID by turning on Developer Mode in Discord's settings, and rightclicking/longpressing on a message.\n\n**Tip:** Reacting to a message with ❓ will DM you this information too.", - "examples": "message 561614629802082304" - }, - { - "name": "invite", - "usage": "invite", - "category": "Utility", - "description": "Sends the bot invite link for PluralKit." - }, - { - "name": "import", - "usage": "import", - "category": "Utility", - "description": "Imports a .json file from Tupperbox.", - "longdesc": "You will need to type the command, *then* send a new message containing the .json file as an attachment." - }, - { - "name": "export", - "usage": "export", - "category": "Utility", - "description": "Exports your system to a .json file.", - "longdesc": "This will respond with a .json file containing your system and member data, useful for importing elsewhere." - }, - { - "name": "token", - "usage": "token", - "category": "API", - "description": "DMs you a token for using the PluralKit API.", - "subcommands": [ - { - "name": "refresh", - "usage": "token refresh", - "category": "API", - "description": "Refreshes your API token.", - "longdesc": "This will invalide the old token and DM you a new one. Do this if your token leaks in any way." - } - ] - }, - { - "name": "help", - "usage": "help [command]", - "category": "Help", - "description": "Displays help for a given command.", - "examples": ["help", "help system", "help member avatar", "help switch move"], - "subcommands": [ - { - "name": "proxy", - "usage": "help proxy", - "category": "Help", - "description": "Displays a short guide to the proxy functionality." - } - ] - }, - { - "name": "commands", - "usage": "commands", - "category": "Help", - "description": "Displays a paginated list of commands", - "examples": ["commands", "commands"] - } - ], - "pages": { - "root": [ - { - "name": "PluralKit", - "content": "PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.\n\n**Who's this for? What are systems?**\nPut simply, a system is a person that shares their body with at least 1 other sentient \"self\". This may be a result of having a dissociative disorder like DID/OSDD or a practice known as Tulpamancy, but people that aren't tulpamancers or undiagnosed and have headmates are also systems.\n\n**Why are people's names saying [BOT] next to them? What's going on?**\nThese people are not actually bots, this is simply a caveat to the message proxying feature of PluralKit.\nType `pk;help proxy` for an in-depth explanation." - }, - { - "name": "Getting started", - "content": "To get started using the bot, try running the following commands.\n**1**. `pk;system new` - Create a system if you haven't already\n**2**. `pk;member add John` - Add a new member to your system\n**3**. `pk;member John proxy [text]` - Set up square brackets as proxy tags\n**4**. You're done!\n**5**. Optionally, you may set an avatar from the URL of an image with:\n`pk;member John avatar [link to image]`\n\nType `pk;help member` for more information." - }, - { - "name": "Useful tips", - "content": "React with ❌ on a proxied message to delete it (if you sent it!).\nReact with ❓ on a proxied message to look up information about it, like who sent it." - }, - { - "name": "More information", - "content": "For a full list of commands, type `pk;commands`.\nFor a more in-depth explanation of message proxying, type `pk;help proxy`.\nIf you're an existing user of the Tupperbox proxy bot, type `pk;import` to import your data from there." - }, - { - "name": "Support server", - "content": "We also have a Discord server for support, discussion, suggestions, announcements, etc: <https://discord.gg/PczBt78>" - } - ], - "proxy": [ - { - "name": "Proxying", - "content": "Proxying through PluralKit lets system members have their own faux-account with their name and avatar.\nYou'll type a message from your account in *proxy tags*, and PluralKit will recognize those tags and repost the message with the proper details, with the minor caveat of having the **[BOT]** icon next to the name (this is a Discord limitation and cannot be circumvented).\n\nTo set up a member's proxy tag, use the `pk;member <name> proxy [example match]` command.\n\nYou'll need to give the bot an \"example match\" containing the word `text`. Imagine you're proxying the word \"text\", and add that to the end of the command. For example: `pk;member John proxy [text]`. That will set the member John up to use square brackets as proxy tags. Now saying something like `[hello world]` will proxy the text \"hello world\" with John's name and avatar. You can also use other symbols, letters, numbers, et cetera, as prefixes, suffixes, or both. `J:text`, `$text` and `text]` are also examples of valid example matches." - } - ] - }, - "footer": "By @Ske#6201 | GitHub: https://github.com/xSke/PluralKit/" -} diff --git a/src/pluralkit/bot/help.py b/src/pluralkit/bot/help.py deleted file mode 100644 index 42a3f62a..00000000 --- a/src/pluralkit/bot/help.py +++ /dev/null @@ -1,6 +0,0 @@ -import json -import os.path - -helpfile = None -with open(os.path.dirname(__file__) + "/help.json", "r") as f: - helpfile = json.load(f) diff --git a/src/pluralkit/bot/proxy.py b/src/pluralkit/bot/proxy.py deleted file mode 100644 index 6f556080..00000000 --- a/src/pluralkit/bot/proxy.py +++ /dev/null @@ -1,254 +0,0 @@ -import asyncio -import re - -import discord -from io import BytesIO -from typing import Optional - -from pluralkit import db -from pluralkit.bot import utils, channel_logger, embeds -from pluralkit.bot.channel_logger import ChannelLogger -from pluralkit.member import Member -from pluralkit.system import System - - -class ProxyError(Exception): - pass - -async def get_or_create_webhook_for_channel(conn, bot_user: discord.User, channel: discord.TextChannel): - # First, check if we have one saved in the DB - webhook_from_db = await db.get_webhook(conn, channel.id) - if webhook_from_db: - webhook_id, webhook_token = webhook_from_db - - session = channel._state.http._session - hook = discord.Webhook.partial(webhook_id, webhook_token, adapter=discord.AsyncWebhookAdapter(session)) - return hook - - try: - # If not, we check to see if there already exists one we've missed - for existing_hook in await channel.webhooks(): - existing_hook_creator = existing_hook.user.id if existing_hook.user else None - is_mine = existing_hook.name == "PluralKit Proxy Webhook" and existing_hook_creator == bot_user.id - if is_mine: - # We found one we made, let's add that to the DB just to be sure - await db.add_webhook(conn, channel.id, existing_hook.id, existing_hook.token) - return existing_hook - - # If not, we create one and save it - created_webhook = await channel.create_webhook(name="PluralKit Proxy Webhook") - except discord.Forbidden: - raise ProxyError( - "PluralKit does not have the \"Manage Webhooks\" permission, and thus cannot proxy your message. Please contact a server administrator.") - - await db.add_webhook(conn, channel.id, created_webhook.id, created_webhook.token) - return created_webhook - - -async def make_attachment_file(message: discord.Message): - if not message.attachments: - return None - - first_attachment = message.attachments[0] - - # Copy the file data to the buffer - # TODO: do this without buffering... somehow - bio = BytesIO() - await first_attachment.save(bio) - - return discord.File(bio, first_attachment.filename) - - -def fix_clyde(name: str) -> str: - # Discord doesn't allow any webhook username to contain the word "Clyde" - # So replace "Clyde" with "C lyde" (except with a hair space, hence \u200A) - # Zero-width spacers are ignored by Discord and will still trigger the error - return re.sub("(c)(lyde)", "\\1\u200A\\2", name, flags=re.IGNORECASE) - - -async def send_proxy_message(conn, original_message: discord.Message, system: System, member: Member, - inner_text: str, logger: ChannelLogger, bot_user: discord.User): - # Send the message through the webhook - webhook = await get_or_create_webhook_for_channel(conn, bot_user, original_message.channel) - - # Bounds check the combined name to avoid silent erroring - full_username = "{} {}".format(member.name, system.tag or "").strip() - full_username = fix_clyde(full_username) - if len(full_username) < 2: - raise ProxyError( - "The webhook's name, `{}`, is shorter than two characters, and thus cannot be proxied. Please change the member name or use a longer system tag.".format( - full_username)) - if len(full_username) > 32: - raise ProxyError( - "The webhook's name, `{}`, is longer than 32 characters, and thus cannot be proxied. Please change the member name or use a shorter system tag.".format( - full_username)) - - try: - sent_message = await webhook.send( - content=inner_text, - username=full_username, - avatar_url=member.avatar_url, - file=await make_attachment_file(original_message), - wait=True - ) - except discord.NotFound: - # The webhook we got from the DB doesn't actually exist - # This can happen if someone manually deletes it from the server - # If we delete it from the DB then call the function again, it'll re-create one for us - # (lol, lazy) - await db.delete_webhook(conn, original_message.channel.id) - await send_proxy_message(conn, original_message, system, member, inner_text, logger, bot_user) - return - - # Save the proxied message in the database - await db.add_message(conn, sent_message.id, original_message.channel.id, member.id, - original_message.author.id) - - # Log it in the log channel if possible - await logger.log_message_proxied( - conn, - original_message.channel.guild.id, - original_message.channel.name, - original_message.channel.id, - original_message.author.name, - original_message.author.discriminator, - original_message.author.id, - member.name, - member.hid, - member.avatar_url, - system.name, - system.hid, - inner_text, - sent_message.attachments[0].url if sent_message.attachments else None, - sent_message.created_at, - sent_message.id - ) - - # And finally, gotta delete the original. - # We wait half a second or so because if the client receives the message deletion - # event before the message actually gets confirmed sent on their end, the message - # doesn't properly get deleted for them, leading to duplication - try: - await asyncio.sleep(0.5) - await original_message.delete() - except discord.Forbidden: - raise ProxyError( - "PluralKit does not have permission to delete user messages. Please contact a server administrator.") - except discord.NotFound: - # Sometimes some other thing will delete the original message before PK gets to it - # This is not a problem - message gets deleted anyway :) - # Usually happens when Tupperware and PK conflict - pass - - -async def try_proxy_message(conn, message: discord.Message, logger: ChannelLogger, bot_user: discord.User) -> bool: - # Don't bother proxying in DMs - if isinstance(message.channel, discord.abc.PrivateChannel): - return False - - # Get the system associated with the account, if possible - system = await System.get_by_account(conn, message.author.id) - if not system: - return False - - # Match on the members' proxy tags - proxy_match = await system.match_proxy(conn, message.content) - if not proxy_match: - return False - - member, inner_message = proxy_match - - # Make sure no @everyones slip through - # Webhooks implicitly have permission to mention @everyone so we have to enforce that manually - inner_message = utils.sanitize(inner_message) - - # If we don't have an inner text OR an attachment, we cancel because the hook can't send that - # Strip so it counts a string of solely spaces as blank too - if not inner_message.strip() and not message.attachments: - return False - - # So, we now have enough information to successfully proxy a message - async with conn.transaction(): - try: - await send_proxy_message(conn, message, system, member, inner_message, logger, bot_user) - except ProxyError as e: - # First, try to send the error in the channel it was triggered in - # Failing that, send the error in a DM. - # Failing *that*... give up, I guess. - try: - await message.channel.send("\u274c {}".format(str(e))) - except discord.Forbidden: - try: - await message.author.send("\u274c {}".format(str(e))) - except discord.Forbidden: - pass - - return True - - -async def handle_deleted_message(conn, client: discord.Client, message_id: int, - message_content: Optional[str], logger: channel_logger.ChannelLogger) -> bool: - msg = await db.get_message(conn, message_id) - if not msg: - return False - - channel = client.get_channel(msg.channel) - if not channel: - # Weird edge case, but channel *could* be deleted at this point (can't think of any scenarios it would be tho) - return False - - await db.delete_message(conn, message_id) - await logger.log_message_deleted( - conn, - channel.guild.id, - channel.name, - msg.name, - msg.hid, - msg.avatar_url, - msg.system_name, - msg.system_hid, - message_content, - message_id - ) - return True - - -async def try_delete_by_reaction(conn, client: discord.Client, message_id: int, reaction_user: int, - logger: channel_logger.ChannelLogger) -> bool: - # Find the message by the given message id or reaction user - msg = await db.get_message_by_sender_and_id(conn, message_id, reaction_user) - if not msg: - # Either the wrong user reacted or the message isn't a proxy message - # In either case - not our problem - return False - - # Find the original message - original_message = await client.get_channel(msg.channel).get_message(message_id) - if not original_message: - # Message got deleted, possibly race condition, eh - return False - - # Then delete the original message - await original_message.delete() - - await handle_deleted_message(conn, client, message_id, original_message.content, logger) - -async def do_query_message(conn, client: discord.Client, queryer_id: int, message_id: int) -> bool: - # Find the message that was queried - msg = await db.get_message(conn, message_id) - if not msg: - return False - - # Then DM the queryer the message embed - card = await embeds.message_card(client, msg, include_pronouns=True) - user = client.get_user(queryer_id) - if not user: - # We couldn't find this user in the cache - bail - return False - - # Send the card to the user - try: - await user.send(embed=card) - except discord.Forbidden: - # User doesn't have DMs enabled, not much we can do about that - pass \ No newline at end of file diff --git a/src/pluralkit/bot/utils.py b/src/pluralkit/bot/utils.py deleted file mode 100644 index 9d2d7d60..00000000 --- a/src/pluralkit/bot/utils.py +++ /dev/null @@ -1,87 +0,0 @@ -import discord -import logging -import re -from typing import Optional - -from pluralkit import db -from pluralkit.member import Member -from pluralkit.system import System - -logger = logging.getLogger("pluralkit.utils") - - -def escape(s): - return s.replace("`", "\\`") - - -def bounds_check_member_name(new_name, system_tag): - if len(new_name) > 32: - return "Name cannot be longer than 32 characters." - - if system_tag: - if len("{} {}".format(new_name, system_tag)) > 32: - return "This name, combined with the system tag ({}), would exceed the maximum length of 32 characters. Please reduce the length of the tag, or use a shorter name.".format( - system_tag) - - -async def parse_mention(client: discord.Client, mention: str) -> Optional[discord.User]: - # First try matching mention format - match = re.fullmatch("<@!?(\\d+)>", mention) - if match: - try: - return await client.get_user_info(int(match.group(1))) - except discord.NotFound: - return None - - # Then try with just ID - try: - return await client.get_user_info(int(mention)) - except (ValueError, discord.NotFound): - return None - - -def parse_channel_mention(mention: str, server: discord.Guild) -> Optional[discord.TextChannel]: - match = re.fullmatch("<#(\\d+)>", mention) - if match: - return server.get_channel(int(match.group(1))) - - try: - return server.get_channel(int(mention)) - except ValueError: - return None - - -async def get_system_fuzzy(conn, client: discord.Client, key) -> Optional[System]: - if isinstance(key, discord.User): - return await db.get_system_by_account(conn, account_id=key.id) - - if isinstance(key, str) and len(key) == 5: - return await db.get_system_by_hid(conn, system_hid=key) - - account = await parse_mention(client, key) - if account: - system = await db.get_system_by_account(conn, account_id=account.id) - if system: - return system - return None - - -async def get_member_fuzzy(conn, system_id: int, key: str, system_only=True) -> Member: - # First search by hid - if system_only: - member = await db.get_member_by_hid_in_system(conn, system_id=system_id, member_hid=key) - else: - member = await db.get_member_by_hid(conn, member_hid=key) - if member is not None: - return member - - # Then search by name, if we have a system - if system_id: - member = await db.get_member_by_name(conn, system_id=system_id, member_name=key) - if member is not None: - return member - - -def sanitize(text): - # Insert a zero-width space in @everyone so it doesn't trigger - return text.replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere") diff --git a/src/pluralkit/db.py b/src/pluralkit/db.py deleted file mode 100644 index fc348e53..00000000 --- a/src/pluralkit/db.py +++ /dev/null @@ -1,383 +0,0 @@ -from collections import namedtuple -from datetime import datetime -import logging -from typing import List, Optional -import time - -import asyncpg -import asyncpg.exceptions -from discord.utils import snowflake_time - -from pluralkit.system import System -from pluralkit.member import Member - -logger = logging.getLogger("pluralkit.db") -async def connect(uri): - while True: - try: - return await asyncpg.create_pool(uri) - except (ConnectionError, asyncpg.exceptions.CannotConnectNowError): - logger.exception("Failed to connect to database, retrying in 5 seconds...") - time.sleep(5) - -def db_wrap(func): - async def inner(*args, **kwargs): - before = time.perf_counter() - try: - res = await func(*args, **kwargs) - after = time.perf_counter() - - logger.debug(" - DB call {} took {:.2f} ms".format(func.__name__, (after - before) * 1000)) - return res - except asyncpg.exceptions.PostgresError: - logger.exception("Error from database query {}".format(func.__name__)) - return inner - -@db_wrap -async def create_system(conn, system_name: str, system_hid: str) -> System: - logger.debug("Creating system (name={}, hid={})".format( - system_name, system_hid)) - row = await conn.fetchrow("insert into systems (name, hid) values ($1, $2) returning *", system_name, system_hid) - return System(**row) if row else None - - -@db_wrap -async def remove_system(conn, system_id: int): - logger.debug("Deleting system (id={})".format(system_id)) - await conn.execute("delete from systems where id = $1", system_id) - - -@db_wrap -async def create_member(conn, system_id: int, member_name: str, member_hid: str) -> Member: - logger.debug("Creating member (system={}, name={}, hid={})".format( - system_id, member_name, member_hid)) - row = await conn.fetchrow("insert into members (name, system, hid) values ($1, $2, $3) returning *", member_name, system_id, member_hid) - return Member(**row) if row else None - - -@db_wrap -async def delete_member(conn, member_id: int): - logger.debug("Deleting member (id={})".format(member_id)) - await conn.execute("delete from members where id = $1", member_id) - - -@db_wrap -async def link_account(conn, system_id: int, account_id: int): - logger.debug("Linking account (account_id={}, system_id={})".format( - account_id, system_id)) - await conn.execute("insert into accounts (uid, system) values ($1, $2)", account_id, system_id) - - -@db_wrap -async def unlink_account(conn, system_id: int, account_id: int): - logger.debug("Unlinking account (account_id={}, system_id={})".format( - account_id, system_id)) - await conn.execute("delete from accounts where uid = $1 and system = $2", account_id, system_id) - - -@db_wrap -async def get_linked_accounts(conn, system_id: int) -> List[int]: - return [row["uid"] for row in await conn.fetch("select uid from accounts where system = $1", system_id)] - - -@db_wrap -async def get_system_by_account(conn, account_id: int) -> System: - row = await conn.fetchrow("select systems.* from systems, accounts where accounts.uid = $1 and accounts.system = systems.id", account_id) - return System(**row) if row else None - -@db_wrap -async def get_system_by_token(conn, token: str) -> Optional[System]: - row = await conn.fetchrow("select * from systems where token = $1", token) - return System(**row) if row else None - -@db_wrap -async def get_system_by_hid(conn, system_hid: str) -> System: - row = await conn.fetchrow("select * from systems where hid = $1", system_hid) - return System(**row) if row else None - - -@db_wrap -async def get_system(conn, system_id: int) -> System: - row = await conn.fetchrow("select * from systems where id = $1", system_id) - return System(**row) if row else None - - -@db_wrap -async def get_member_by_name(conn, system_id: int, member_name: str) -> Member: - row = await conn.fetchrow("select * from members where system = $1 and lower(name) = lower($2)", system_id, member_name) - return Member(**row) if row else None - - -@db_wrap -async def get_member_by_hid_in_system(conn, system_id: int, member_hid: str) -> Member: - row = await conn.fetchrow("select * from members where system = $1 and hid = $2", system_id, member_hid) - return Member(**row) if row else None - - -@db_wrap -async def get_member_by_hid(conn, member_hid: str) -> Member: - row = await conn.fetchrow("select * from members where hid = $1", member_hid) - return Member(**row) if row else None - - -@db_wrap -async def get_member(conn, member_id: int) -> Member: - row = await conn.fetchrow("select * from members where id = $1", member_id) - return Member(**row) if row else None - -@db_wrap -async def get_members(conn, members: list) -> List[Member]: - rows = await conn.fetch("select * from members where id = any($1)", members) - return [Member(**row) for row in rows] - -@db_wrap -async def update_system_field(conn, system_id: int, field: str, value): - logger.debug("Updating system field (id={}, {}={})".format( - system_id, field, value)) - await conn.execute("update systems set {} = $1 where id = $2".format(field), value, system_id) - - -@db_wrap -async def update_member_field(conn, member_id: int, field: str, value): - logger.debug("Updating member field (id={}, {}={})".format( - member_id, field, value)) - await conn.execute("update members set {} = $1 where id = $2".format(field), value, member_id) - - -@db_wrap -async def get_all_members(conn, system_id: int) -> List[Member]: - rows = await conn.fetch("select * from members where system = $1", system_id) - return [Member(**row) for row in rows] - -@db_wrap -async def get_members_exceeding(conn, system_id: int, length: int) -> List[Member]: - rows = await conn.fetch("select * from members where system = $1 and length(name) > $2", system_id, length) - return [Member(**row) for row in rows] - - -@db_wrap -async def get_webhook(conn, channel_id: int) -> (str, str): - row = await conn.fetchrow("select webhook, token from webhooks where channel = $1", channel_id) - return (str(row["webhook"]), row["token"]) if row else None - - -@db_wrap -async def add_webhook(conn, channel_id: int, webhook_id: int, webhook_token: str): - logger.debug("Adding new webhook (channel={}, webhook={}, token={})".format( - channel_id, webhook_id, webhook_token)) - await conn.execute("insert into webhooks (channel, webhook, token) values ($1, $2, $3)", channel_id, webhook_id, webhook_token) - -@db_wrap -async def delete_webhook(conn, channel_id: int): - await conn.execute("delete from webhooks where channel = $1", channel_id) - -@db_wrap -async def add_message(conn, message_id: int, channel_id: int, member_id: int, sender_id: int): - logger.debug("Adding new message (id={}, channel={}, member={}, sender={})".format( - message_id, channel_id, member_id, sender_id)) - await conn.execute("insert into messages (mid, channel, member, sender) values ($1, $2, $3, $4)", message_id, channel_id, member_id, sender_id) - -class ProxyMember(namedtuple("ProxyMember", ["id", "hid", "prefix", "suffix", "color", "name", "avatar_url", "tag", "system_name", "system_hid"])): - id: int - hid: str - prefix: str - suffix: str - color: str - name: str - avatar_url: str - tag: str - system_name: str - system_hid: str - -@db_wrap -async def get_members_by_account(conn, account_id: int) -> List[ProxyMember]: - # Returns a "chimera" object - rows = await conn.fetch("""select - members.id, members.hid, members.prefix, members.suffix, members.color, members.name, members.avatar_url, - systems.tag, systems.name as system_name, systems.hid as system_hid - from - systems, members, accounts - where - accounts.uid = $1 - and systems.id = accounts.system - and members.system = systems.id""", account_id) - return [ProxyMember(**row) for row in rows] - -class MessageInfo(namedtuple("MemberInfo", ["mid", "channel", "member", "sender", "name", "hid", "avatar_url", "system_name", "system_hid", "pronouns"])): - mid: int - channel: int - member: int - sender: int - name: str - hid: str - avatar_url: str - system_name: str - system_hid: str - pronouns: str - - def to_json(self): - return { - "id": str(self.mid), - "channel": str(self.channel), - "member": self.hid, - "system": self.system_hid, - "message_sender": str(self.sender), - "timestamp": snowflake_time(self.mid).isoformat() - } - -@db_wrap -async def get_message_by_sender_and_id(conn, message_id: int, sender_id: int) -> MessageInfo: - row = await conn.fetchrow("""select - messages.*, - members.name, members.hid, members.avatar_url, members.pronouns, - systems.name as system_name, systems.hid as system_hid - from - messages, members, systems - where - messages.member = members.id - and members.system = systems.id - and mid = $1 - and sender = $2""", message_id, sender_id) - return MessageInfo(**row) if row else None - - -@db_wrap -async def get_message(conn, message_id: int) -> MessageInfo: - row = await conn.fetchrow("""select - messages.*, - members.name, members.hid, members.avatar_url, members.pronouns, - systems.name as system_name, systems.hid as system_hid - from - messages, members, systems - where - messages.member = members.id - and members.system = systems.id - and mid = $1""", message_id) - return MessageInfo(**row) if row else None - - -@db_wrap -async def delete_message(conn, message_id: int): - logger.debug("Deleting message (id={})".format(message_id)) - await conn.execute("delete from messages where mid = $1", message_id) - -@db_wrap -async def get_member_message_count(conn, member_id: int) -> int: - return await conn.fetchval("select count(*) from messages where member = $1", member_id) - -@db_wrap -async def front_history(conn, system_id: int, count: int): - return await conn.fetch("""select - switches.*, - array( - select member from switch_members - where switch_members.switch = switches.id - order by switch_members.id asc - ) as members - from switches - where switches.system = $1 - order by switches.timestamp desc - limit $2""", system_id, count) - -@db_wrap -async def add_switch(conn, system_id: int): - logger.debug("Adding switch (system={})".format(system_id)) - res = await conn.fetchrow("insert into switches (system) values ($1) returning *", system_id) - return res["id"] - -@db_wrap -async def move_switch(conn, system_id: int, switch_id: int, new_time: datetime): - logger.debug("Moving latest switch (system={}, id={}, new_time={})".format(system_id, switch_id, new_time)) - await conn.execute("update switches set timestamp = $1 where system = $2 and id = $3", new_time, system_id, switch_id) - -@db_wrap -async def add_switch_member(conn, switch_id: int, member_id: int): - logger.debug("Adding switch member (switch={}, member={})".format(switch_id, member_id)) - await conn.execute("insert into switch_members (switch, member) values ($1, $2)", switch_id, member_id) - -@db_wrap -async def delete_switch(conn, switch_id: int): - logger.debug("Deleting switch (id={})".format(switch_id)) - await conn.execute("delete from switches where id = $1", switch_id) - -@db_wrap -async def get_server_info(conn, server_id: int): - return await conn.fetchrow("select * from servers where id = $1", server_id) - -@db_wrap -async def update_server(conn, server_id: int, logging_channel_id: int): - logging_channel_id = logging_channel_id if logging_channel_id else None - logger.debug("Updating server settings (id={}, log_channel={})".format(server_id, logging_channel_id)) - await conn.execute("insert into servers (id, log_channel) values ($1, $2) on conflict (id) do update set log_channel = $2", server_id, logging_channel_id) - -@db_wrap -async def member_count(conn) -> int: - return await conn.fetchval("select count(*) from members") - -@db_wrap -async def system_count(conn) -> int: - return await conn.fetchval("select count(*) from systems") - -@db_wrap -async def message_count(conn) -> int: - return await conn.fetchval("select count(*) from messages") - -@db_wrap -async def account_count(conn) -> int: - return await conn.fetchval("select count(*) from accounts") - -async def create_tables(conn): - await conn.execute("""create table if not exists systems ( - id serial primary key, - hid char(5) unique not null, - name text, - description text, - tag text, - avatar_url text, - token text, - created timestamp not null default (current_timestamp at time zone 'utc'), - ui_tz text not null default 'UTC' - )""") - await conn.execute("""create table if not exists members ( - id serial primary key, - hid char(5) unique not null, - system serial not null references systems(id) on delete cascade, - color char(6), - avatar_url text, - name text not null, - birthday date, - pronouns text, - description text, - prefix text, - suffix text, - created timestamp not null default (current_timestamp at time zone 'utc') - )""") - await conn.execute("""create table if not exists accounts ( - uid bigint primary key, - system serial not null references systems(id) on delete cascade - )""") - await conn.execute("""create table if not exists messages ( - mid bigint primary key, - channel bigint not null, - member serial not null references members(id) on delete cascade, - sender bigint not null - )""") - await conn.execute("""create table if not exists switches ( - id serial primary key, - system serial not null references systems(id) on delete cascade, - timestamp timestamp not null default (current_timestamp at time zone 'utc') - )""") - await conn.execute("""create table if not exists switch_members ( - id serial primary key, - switch serial not null references switches(id) on delete cascade, - member serial not null references members(id) on delete cascade - )""") - await conn.execute("""create table if not exists webhooks ( - channel bigint primary key, - webhook bigint not null, - token text not null - )""") - await conn.execute("""create table if not exists servers ( - id bigint primary key, - log_channel bigint - )""") diff --git a/src/pluralkit/errors.py b/src/pluralkit/errors.py deleted file mode 100644 index ae084adb..00000000 --- a/src/pluralkit/errors.py +++ /dev/null @@ -1,104 +0,0 @@ -from typing import Tuple - - -class PluralKitError(Exception): - def __init__(self, message): - self.message = message - self.help_page = None - - def with_help(self, help_page: Tuple[str, str]): - self.help_page = help_page - - -class ExistingSystemError(PluralKitError): - def __init__(self): - super().__init__( - "You already have a system registered. To delete your system, use `pk;system delete`, or to unlink your system from this account, use `pk;system unlink`.") - - -class DescriptionTooLongError(PluralKitError): - def __init__(self): - super().__init__("You can't have a description longer than 1024 characters.") - - -class TagTooLongError(PluralKitError): - def __init__(self): - super().__init__("You can't have a system tag longer than 32 characters.") - - -class TagTooLongWithMembersError(PluralKitError): - def __init__(self, member_names): - super().__init__( - "The maximum length of a name plus the system tag is 32 characters. The following members would exceed the limit: {}. Please reduce the length of the tag, or rename the members.".format( - ", ".join(member_names))) - self.member_names = member_names - - -class CustomEmojiError(PluralKitError): - def __init__(self): - super().__init__( - "Due to a Discord limitation, custom emojis aren't supported. Please use a standard emoji instead.") - - -class InvalidAvatarURLError(PluralKitError): - def __init__(self): - super().__init__("Invalid image URL.") - - -class AccountInOwnSystemError(PluralKitError): - def __init__(self): - super().__init__("That account is already linked to your own system.") - - -class AccountAlreadyLinkedError(PluralKitError): - def __init__(self, existing_system): - super().__init__("The mentioned account is already linked to a system (`{}`)".format(existing_system.hid)) - self.existing_system = existing_system - - -class UnlinkingLastAccountError(PluralKitError): - def __init__(self): - super().__init__("This is the only account on your system, so you can't unlink it.") - - -class MemberNameTooLongError(PluralKitError): - def __init__(self, tag_present: bool): - if tag_present: - super().__init__( - "The maximum length of a name plus the system tag is 32 characters. Please reduce the length of the tag, or choose a shorter member name.") - else: - super().__init__("The maximum length of a member name is 32 characters.") - - -class InvalidColorError(PluralKitError): - def __init__(self): - super().__init__("Color must be a valid hex color. (eg. #ff0000)") - - -class InvalidDateStringError(PluralKitError): - def __init__(self): - super().__init__("Invalid date string. Date must be in ISO-8601 format (YYYY-MM-DD, eg. 1999-07-25).") - - -class MembersAlreadyFrontingError(PluralKitError): - def __init__(self, members: "List[Member]"): - if len(members) == 0: - super().__init__("There are already no members fronting.") - elif len(members) == 1: - super().__init__("Member {} is already fronting.".format(members[0].name)) - else: - super().__init__("Members {} are already fronting.".format(", ".join([member.name for member in members]))) - - -class DuplicateSwitchMembersError(PluralKitError): - def __init__(self): - super().__init__("Duplicate members in member list.") - - -class InvalidTimeZoneError(PluralKitError): - def __init__(self, tz_name: str): - super().__init__("Invalid time zone designation \"{}\".\n\nFor a list of valid time zone designations, see the `TZ database name` column here: <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List>.".format(tz_name)) - -class TupperboxImportError(PluralKitError): - def __init__(self): - super().__init__("Invalid Tupperbox file.") \ No newline at end of file diff --git a/src/pluralkit/member.py b/src/pluralkit/member.py deleted file mode 100644 index e058b39f..00000000 --- a/src/pluralkit/member.py +++ /dev/null @@ -1,177 +0,0 @@ -import re -from datetime import date, datetime - -from collections.__init__ import namedtuple -from typing import Optional, Union - -from pluralkit import db, errors -from pluralkit.utils import validate_avatar_url_or_raise, contains_custom_emoji - - -class Member(namedtuple("Member", - ["id", "hid", "system", "color", "avatar_url", "name", "birthday", "pronouns", "description", - "prefix", "suffix", "created"])): - """An immutable representation of a system member fetched from the database.""" - id: int - hid: str - system: int - color: str - avatar_url: str - name: str - birthday: date - pronouns: str - description: str - prefix: str - suffix: str - created: datetime - - def to_json(self): - return { - "id": self.hid, - "name": self.name, - "color": self.color, - "avatar_url": self.avatar_url, - "birthday": self.birthday.isoformat() if self.birthday else None, - "pronouns": self.pronouns, - "description": self.description, - "prefix": self.prefix, - "suffix": self.suffix - } - - @staticmethod - async def get_member_by_id(conn, member_id: int) -> Optional["Member"]: - """Fetch a member with the given internal member ID from the database.""" - return await db.get_member(conn, member_id) - - @staticmethod - async def get_member_by_name(conn, system_id: int, member_name: str) -> "Optional[Member]": - """Fetch a member by the given name in the given system from the database.""" - member = await db.get_member_by_name(conn, system_id, member_name) - return member - - @staticmethod - async def get_member_by_hid(conn, system_id: Optional[int], member_hid: str) -> "Optional[Member]": - """Fetch a member by the given hid from the database. If @`system_id` is present, will only return members from that system.""" - if system_id: - member = await db.get_member_by_hid_in_system(conn, system_id, member_hid) - else: - member = await db.get_member_by_hid(conn, member_hid) - - return member - - @staticmethod - async def get_member_fuzzy(conn, system_id: int, name: str) -> "Optional[Member]": - by_hid = await Member.get_member_by_hid(conn, system_id, name) - if by_hid: - return by_hid - - by_name = await Member.get_member_by_name(conn, system_id, name) - return by_name - - - async def set_name(self, conn, new_name: str): - """ - Set the name of a member. - :raises: CustomEmojiError - """ - # Custom emojis can't go in the member name - # Technically they *could*, but they wouldn't render properly - # so I'd rather explicitly ban them to in order to avoid confusion - if contains_custom_emoji(new_name): - raise errors.CustomEmojiError() - - await db.update_member_field(conn, self.id, "name", new_name) - - async def set_description(self, conn, new_description: Optional[str]): - """ - Set or clear the description of a member. - :raises: DescriptionTooLongError - """ - # Explicit length checking - if new_description and len(new_description) > 1024: - raise errors.DescriptionTooLongError() - - await db.update_member_field(conn, self.id, "description", new_description) - - async def set_avatar(self, conn, new_avatar_url: Optional[str]): - """ - Set or clear the avatar of a member. - :raises: InvalidAvatarURLError - """ - if new_avatar_url: - validate_avatar_url_or_raise(new_avatar_url) - - await db.update_member_field(conn, self.id, "avatar_url", new_avatar_url) - - async def set_color(self, conn, new_color: Optional[str]): - """ - Set or clear the associated color of a member. - :raises: InvalidColorError - """ - cleaned_color = None - if new_color: - match = re.fullmatch("#?([0-9A-Fa-f]{6})", new_color) - if not match: - raise errors.InvalidColorError() - - cleaned_color = match.group(1).lower() - - await db.update_member_field(conn, self.id, "color", cleaned_color) - - async def set_birthdate(self, conn, new_date: Union[date, str]): - """ - Set or clear the birthdate of a member. To hide the birth year, pass a year of 0001. - - If passed a string, will attempt to parse the string as a date. - :raises: InvalidDateStringError - """ - - if isinstance(new_date, str): - date_str = new_date - try: - new_date = datetime.strptime(date_str, "%Y-%m-%d").date() - except ValueError: - try: - # Try again, adding 0001 as a placeholder year - # This is considered a "null year" and will be omitted from the info card - # Useful if you want your birthday to be displayed yearless. - new_date = datetime.strptime("0001-" + date_str, "%Y-%m-%d").date() - except ValueError: - raise errors.InvalidDateStringError() - - await db.update_member_field(conn, self.id, "birthday", new_date) - - async def set_pronouns(self, conn, new_pronouns: str): - """Set or clear the associated pronouns with a member.""" - await db.update_member_field(conn, self.id, "pronouns", new_pronouns) - - async def set_proxy_tags(self, conn, prefix: Optional[str], suffix: Optional[str]): - """ - Set the proxy tags for a member. Having no prefix *and* no suffix will disable proxying. - """ - # Make sure empty strings or other falsey values are actually None - prefix = prefix or None - suffix = suffix or None - - async with conn.transaction(): - await db.update_member_field(conn, member_id=self.id, field="prefix", value=prefix) - await db.update_member_field(conn, member_id=self.id, field="suffix", value=suffix) - - async def delete(self, conn): - """Delete this member from the database.""" - await db.delete_member(conn, self.id) - - async def fetch_system(self, conn) -> "System": - """Fetch the member's system from the database""" - return await db.get_system(conn, self.system) - - async def message_count(self, conn) -> int: - return await db.get_member_message_count(conn, self.id) - - def birthday_string(self) -> Optional[str]: - if not self.birthday: - return None - - if self.birthday.year == 1: - return self.birthday.strftime("%b %d") - return self.birthday.strftime("%b %d, %Y") \ No newline at end of file diff --git a/src/pluralkit/switch.py b/src/pluralkit/switch.py deleted file mode 100644 index 7536fa63..00000000 --- a/src/pluralkit/switch.py +++ /dev/null @@ -1,28 +0,0 @@ -from collections import namedtuple -from datetime import datetime -from typing import List - -from pluralkit import db -from pluralkit.member import Member - - -class Switch(namedtuple("Switch", ["id", "system", "timestamp", "members"])): - id: int - system: int - timestamp: datetime - members: List[int] - - async def fetch_members(self, conn) -> List[Member]: - return await db.get_members(conn, self.members) - - async def delete(self, conn): - await db.delete_switch(conn, self.id) - - async def move(self, conn, new_timestamp): - await db.move_switch(conn, self.system, self.id, new_timestamp) - - async def to_json(self, hid_getter): - return { - "timestamp": self.timestamp.isoformat(), - "members": [await hid_getter(m) for m in self.members] - } diff --git a/src/pluralkit/system.py b/src/pluralkit/system.py deleted file mode 100644 index 2112c650..00000000 --- a/src/pluralkit/system.py +++ /dev/null @@ -1,322 +0,0 @@ -import random -import re -import string -from collections.__init__ import namedtuple -from datetime import datetime -from typing import Optional, List, Tuple - -import pytz - -from pluralkit import db, errors -from pluralkit.member import Member -from pluralkit.switch import Switch -from pluralkit.utils import generate_hid, contains_custom_emoji, validate_avatar_url_or_raise - -class TupperboxImportResult(namedtuple("TupperboxImportResult", ["updated", "created", "tags"])): - pass - -class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "avatar_url", "token", "created", "ui_tz"])): - id: int - hid: str - name: str - description: str - tag: str - avatar_url: str - token: str - created: datetime - # pytz-compatible time zone name, usually Olson-style (eg. Europe/Amsterdam) - ui_tz: str - - @staticmethod - async def get_by_id(conn, system_id: int) -> Optional["System"]: - return await db.get_system(conn, system_id) - - @staticmethod - async def get_by_account(conn, account_id: int) -> Optional["System"]: - return await db.get_system_by_account(conn, account_id) - - @staticmethod - async def get_by_token(conn, token: str) -> Optional["System"]: - return await db.get_system_by_token(conn, token) - - @staticmethod - async def get_by_hid(conn, hid: str) -> Optional["System"]: - return await db.get_system_by_hid(conn, hid) - - @staticmethod - async def create_system(conn, account_id: int, system_name: Optional[str] = None) -> "System": - async with conn.transaction(): - existing_system = await System.get_by_account(conn, account_id) - if existing_system: - raise errors.ExistingSystemError() - - new_hid = generate_hid() - - async with conn.transaction(): - new_system = await db.create_system(conn, system_name, new_hid) - await db.link_account(conn, new_system.id, account_id) - - return new_system - - async def set_name(self, conn, new_name: Optional[str]): - await db.update_system_field(conn, self.id, "name", new_name) - - async def set_description(self, conn, new_description: Optional[str]): - # Explicit length error - if new_description and len(new_description) > 1024: - raise errors.DescriptionTooLongError() - - await db.update_system_field(conn, self.id, "description", new_description) - - async def set_tag(self, conn, new_tag: Optional[str]): - if new_tag: - # Explicit length error - if len(new_tag) > 32: - raise errors.TagTooLongError() - - if contains_custom_emoji(new_tag): - raise errors.CustomEmojiError() - - await db.update_system_field(conn, self.id, "tag", new_tag) - - async def set_avatar(self, conn, new_avatar_url: Optional[str]): - if new_avatar_url: - validate_avatar_url_or_raise(new_avatar_url) - - await db.update_system_field(conn, self.id, "avatar_url", new_avatar_url) - - async def link_account(self, conn, new_account_id: int): - async with conn.transaction(): - existing_system = await System.get_by_account(conn, new_account_id) - - if existing_system: - if existing_system.id == self.id: - raise errors.AccountInOwnSystemError() - - raise errors.AccountAlreadyLinkedError(existing_system) - - await db.link_account(conn, self.id, new_account_id) - - async def unlink_account(self, conn, account_id: int): - async with conn.transaction(): - linked_accounts = await db.get_linked_accounts(conn, self.id) - if len(linked_accounts) == 1: - raise errors.UnlinkingLastAccountError() - - await db.unlink_account(conn, self.id, account_id) - - async def get_linked_account_ids(self, conn) -> List[int]: - return await db.get_linked_accounts(conn, self.id) - - async def delete(self, conn): - await db.remove_system(conn, self.id) - - async def refresh_token(self, conn) -> str: - new_token = "".join(random.choices(string.ascii_letters + string.digits, k=64)) - await db.update_system_field(conn, self.id, "token", new_token) - return new_token - - async def get_token(self, conn) -> str: - if self.token: - return self.token - return await self.refresh_token(conn) - - async def create_member(self, conn, member_name: str) -> Member: - # TODO: figure out what to do if this errors out on collision on generate_hid - new_hid = generate_hid() - - if len(member_name) > self.get_member_name_limit(): - raise errors.MemberNameTooLongError(tag_present=bool(self.tag)) - - member = await db.create_member(conn, self.id, member_name, new_hid) - return member - - async def get_members(self, conn) -> List[Member]: - return await db.get_all_members(conn, self.id) - - async def get_switches(self, conn, count) -> List[Switch]: - """Returns the latest `count` switches logged for this system, ordered latest to earliest.""" - return [Switch(**s) for s in await db.front_history(conn, self.id, count)] - - async def get_latest_switch(self, conn) -> Optional[Switch]: - """Returns the latest switch logged for this system, or None if no switches have been logged""" - switches = await self.get_switches(conn, 1) - if switches: - return switches[0] - else: - return None - - async def add_switch(self, conn, members: List[Member]) -> Switch: - """ - Logs a new switch for a system. - - :raises: MembersAlreadyFrontingError, DuplicateSwitchMembersError - """ - new_ids = [member.id for member in members] - - last_switch = await self.get_latest_switch(conn) - - # If we have a switch logged before, make sure this isn't a dupe switch - if last_switch: - last_switch_members = await last_switch.fetch_members(conn) - last_ids = [member.id for member in last_switch_members] - - # We don't compare by set() here because swapping multiple is a valid operation - if last_ids == new_ids: - raise errors.MembersAlreadyFrontingError(members) - - # Check for dupes - if len(set(new_ids)) != len(new_ids): - raise errors.DuplicateSwitchMembersError() - - async with conn.transaction(): - switch_id = await db.add_switch(conn, self.id) - - # TODO: batch query here - for member in members: - await db.add_switch_member(conn, switch_id, member.id) - - return await self.get_latest_switch(conn) - - def get_member_name_limit(self) -> int: - """Returns the maximum length a member's name or nickname is allowed to be in order for the member to be proxied. Depends on the system tag.""" - if self.tag: - return 32 - len(self.tag) - 1 - else: - return 32 - - async def match_proxy(self, conn, message: str) -> Optional[Tuple[Member, str]]: - """Tries to find a member with proxy tags matching the given message. Returns the member and the inner contents.""" - members = await db.get_all_members(conn, self.id) - - # Sort by specificity (members with both prefix and suffix defined go higher) - # This will make sure more "precise" proxy tags get tried first and match properly - members = sorted(members, key=lambda x: int(bool(x.prefix)) + int(bool(x.suffix)), reverse=True) - - for member in members: - proxy_prefix = member.prefix or "" - proxy_suffix = member.suffix or "" - - if not proxy_prefix and not proxy_suffix: - # If the member has neither a prefix or a suffix, cancel early - # Otherwise it'd match any message no matter what - continue - - # Check if the message matches these tags - if message.startswith(proxy_prefix) and message.endswith(proxy_suffix): - # If the message starts with a mention, "separate" that and match the bit after - mention_match = re.match(r"^(<(@|@!|#|@&|a?:\w+:)\d+>\s*)+", message) - leading_mentions = "" - if mention_match: - message = message[mention_match.span(0)[1]:].strip() - leading_mentions = mention_match.group(0) - - # Extract the inner message (special case because -0 is invalid as an end slice) - if len(proxy_suffix) == 0: - inner_message = message[len(proxy_prefix):] - else: - inner_message = message[len(proxy_prefix):-len(proxy_suffix)] - - # Add the stripped mentions back if there are any - inner_message = leading_mentions + inner_message - return member, inner_message - - def format_time(self, dt: datetime) -> str: - """ - Localizes the given `datetime` to a string based on the system's preferred time zone. - - Assumes `dt` is a naïve `datetime` instance set to UTC, which is consistent with the rest of PluralKit. - """ - tz = pytz.timezone(self.ui_tz) - - # Set to aware (UTC), convert to tz, set to naive (tz), then format and append name - return tz.normalize(pytz.utc.localize(dt)).replace(tzinfo=None).isoformat(sep=" ", timespec="seconds") + " " + tz.tzname(dt) - - async def set_time_zone(self, conn, tz_name: str) -> pytz.tzinfo: - """ - Sets the system time zone to the time zone represented by the given string. - - If `tz_name` is None or an empty string, will default to UTC. - If `tz_name` does not represent a valid time zone string, will raise InvalidTimeZoneError. - - :raises: InvalidTimeZoneError - :returns: The `pytz.tzinfo` instance of the newly set time zone. - """ - - try: - tz = pytz.timezone(tz_name or "UTC") - except pytz.UnknownTimeZoneError: - raise errors.InvalidTimeZoneError(tz_name) - - await db.update_system_field(conn, self.id, "ui_tz", tz.zone) - return tz - - async def import_from_tupperbox(self, conn, data: dict): - """ - Imports from a Tupperbox JSON data file. - :raises: TupperboxImportError - """ - if not "tuppers" in data: - raise errors.TupperboxImportError() - if not isinstance(data["tuppers"], list): - raise errors.TupperboxImportError() - - all_tags = set() - created_members = set() - updated_members = set() - for tupper in data["tuppers"]: - # Sanity check tupper fields - for field in ["name", "avatar_url", "brackets", "birthday", "description", "tag"]: - if field not in tupper: - raise errors.TupperboxImportError() - - # Find member by name, create if not exists - member_name = str(tupper["name"]) - member = await Member.get_member_by_name(conn, self.id, member_name) - if not member: - # And keep track of created members - created_members.add(member_name) - member = await self.create_member(conn, member_name) - else: - # Keep track of updated members - updated_members.add(member_name) - - # Set avatar - await member.set_avatar(conn, str(tupper["avatar_url"])) - - # Set proxy tags - if not (isinstance(tupper["brackets"], list) and len(tupper["brackets"]) >= 2): - raise errors.TupperboxImportError() - await member.set_proxy_tags(conn, str(tupper["brackets"][0]), str(tupper["brackets"][1])) - - # Set birthdate (input is in ISO-8601, first 10 characters is the date) - if tupper["birthday"]: - try: - await member.set_birthdate(conn, str(tupper["birthday"][:10])) - except errors.InvalidDateStringError: - pass - - # Set description - await member.set_description(conn, tupper["description"]) - - # Keep track of tag - all_tags.add(tupper["tag"]) - - # Since Tupperbox does tags on a per-member basis, we only apply a system tag if - # every member has the same tag (surprisingly common) - # If not, we just do nothing. (This will be reported in the caller function through the returned result) - if len(all_tags) == 1: - tag = list(all_tags)[0] - await self.set_tag(conn, tag) - - return TupperboxImportResult(updated=updated_members, created=created_members, tags=all_tags) - - def to_json(self): - return { - "id": self.hid, - "name": self.name, - "description": self.description, - "tag": self.tag, - "avatar_url": self.avatar_url, - "tz": self.ui_tz - } diff --git a/src/pluralkit/utils.py b/src/pluralkit/utils.py deleted file mode 100644 index 424c23a6..00000000 --- a/src/pluralkit/utils.py +++ /dev/null @@ -1,73 +0,0 @@ -import humanize -import re - -import random -import string -from datetime import datetime, timezone, timedelta -from typing import List, Tuple, Union -from urllib.parse import urlparse - -from pluralkit import db -from pluralkit.errors import InvalidAvatarURLError - - -def display_relative(time: Union[datetime, timedelta]) -> str: - if isinstance(time, datetime): - time = datetime.utcnow() - time - return humanize.naturaldelta(time) - - -async def get_fronter_ids(conn, system_id) -> (List[int], datetime): - switches = await db.front_history(conn, system_id=system_id, count=1) - if not switches: - return [], None - - if not switches[0]["members"]: - return [], switches[0]["timestamp"] - - return switches[0]["members"], switches[0]["timestamp"] - - -async def get_fronters(conn, system_id) -> (List["Member"], datetime): - member_ids, timestamp = await get_fronter_ids(conn, system_id) - - # Collect in dict and then look up as list, to preserve return order - members = {member.id: member for member in await db.get_members(conn, member_ids)} - return [members[member_id] for member_id in member_ids], timestamp - - -async def get_front_history(conn, system_id, count) -> List[Tuple[datetime, List["pluMember"]]]: - # Get history from DB - switches = await db.front_history(conn, system_id=system_id, count=count) - if not switches: - return [] - - # Get all unique IDs referenced - all_member_ids = {id for switch in switches for id in switch["members"]} - - # And look them up in the database into a dict - all_members = {member.id: member for member in await db.get_members(conn, list(all_member_ids))} - - # Collect in array and return - out = [] - for switch in switches: - timestamp = switch["timestamp"] - members = [all_members[id] for id in switch["members"]] - out.append((timestamp, members)) - return out - - -def generate_hid() -> str: - return "".join(random.choices(string.ascii_lowercase, k=5)) - - -def contains_custom_emoji(value): - return bool(re.search("<a?:\w+:\d+>", value)) - - -def validate_avatar_url_or_raise(url): - u = urlparse(url) - if not (u.scheme in ["http", "https"] and u.netloc and u.path): - raise InvalidAvatarURLError() - - # TODO: check file type and size of image \ No newline at end of file diff --git a/src/requirements.txt b/src/requirements.txt deleted file mode 100644 index 8a69d860..00000000 --- a/src/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -aiodns -aiohttp==3.3.0 -asyncpg -dateparser -https://github.com/Rapptz/discord.py/archive/aceec2009a7c819d2236884fa9ccc5ce58a92bea.zip#egg=discord.py -humanize -uvloop; sys.platform != 'win32' and sys.platform != 'cygwin' and sys.platform != 'cli' -ciso8601 -pytz -timezonefinder From 0d6b6bf08e47d372a42d0cee12912510e4a7afb4 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sat, 20 Apr 2019 22:25:03 +0200 Subject: [PATCH 002/103] bot: trace startup sequence to stdout --- .vscode/launch.json | 32 ++++++++++++++++++++++---------- PluralKit/Bot.cs | 14 ++++++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 841353a4..ed7f245b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,16 +1,28 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + "version": "0.2.0", + "configurations": [ { - "name": "PluralKit", - "type": "python", + "name": ".NET Core Launch (console)", + "type": "coreclr", "request": "launch", - "program": "${workspaceRoot}/src/bot_main.py", - "args": ["${workspaceRoot}/pluralkit.conf"], - "console": "integratedTerminal", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/bin/Debug/netcoreapp2.2/PluralKit.dll", + "args": [], + "cwd": "${workspaceFolder}", + // For more information about the 'console' field, see https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#console-terminal-window + "console": "internalConsole", + "stopAtEntry": false, + "envFile": "${workspaceFolder}/.env" + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}" } ] } \ No newline at end of file diff --git a/PluralKit/Bot.cs b/PluralKit/Bot.cs index 128cefa8..8d578d14 100644 --- a/PluralKit/Bot.cs +++ b/PluralKit/Bot.cs @@ -23,6 +23,8 @@ namespace PluralKit private async Task MainAsync() { + Console.WriteLine("Starting PluralKit..."); + // Dapper by default tries to pass ulongs to Npgsql, which rejects them since PostgreSQL technically // doesn't support unsigned types on its own. // Instead we add a custom mapper to encode them as signed integers instead, converting them back and forth. @@ -32,15 +34,19 @@ namespace PluralKit using (var services = BuildServiceProvider()) { + Console.WriteLine("- Connecting to database..."); var connection = services.GetRequiredService<IDbConnection>() as NpgsqlConnection; connection.ConnectionString = Environment.GetEnvironmentVariable("PK_DATABASE_URI"); await connection.OpenAsync(); + Console.WriteLine("- Connecting to Discord..."); var client = services.GetRequiredService<IDiscordClient>() as DiscordSocketClient; await client.LoginAsync(TokenType.Bot, Environment.GetEnvironmentVariable("PK_TOKEN")); await client.StartAsync(); + Console.WriteLine("- Initializing bot..."); await services.GetRequiredService<Bot>().Init(); + await Task.Delay(-1); } } @@ -85,11 +91,19 @@ namespace PluralKit _commands.CommandExecuted += CommandExecuted; await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services); + _client.Ready += Ready; _client.MessageReceived += MessageReceived; _client.ReactionAdded += _proxy.HandleReactionAddedAsync; _client.MessageDeleted += _proxy.HandleMessageDeletedAsync; } + private Task Ready() + { + Console.WriteLine($"Shard #{_client.ShardId} connected to {_client.Guilds.Sum(g => g.Channels.Count)} channels in {_client.Guilds.Count} guilds."); + Console.WriteLine($"PluralKit started as {_client.CurrentUser.Username}#{_client.CurrentUser.Discriminator} ({_client.CurrentUser.Id})."); + return Task.CompletedTask; + } + private async Task CommandExecuted(Optional<CommandInfo> cmd, ICommandContext ctx, IResult _result) { if (!_result.IsSuccess) { From 41d9c84d76a667e200c3f2995aa996937db3c3f0 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sat, 20 Apr 2019 22:36:54 +0200 Subject: [PATCH 003/103] bot: add generic runtime error handler --- PluralKit/Bot.cs | 47 +++++++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/PluralKit/Bot.cs b/PluralKit/Bot.cs index 8d578d14..02e12e63 100644 --- a/PluralKit/Bot.cs +++ b/PluralKit/Bot.cs @@ -113,27 +113,38 @@ namespace PluralKit private async Task MessageReceived(SocketMessage _arg) { - // Ignore system messages (member joined, message pinned, etc) - var arg = _arg as SocketUserMessage; - if (arg == null) return; + try { + // Ignore system messages (member joined, message pinned, etc) + var arg = _arg as SocketUserMessage; + if (arg == null) return; - // Ignore bot messages - if (arg.Author.IsBot || arg.Author.IsWebhook) return; + // Ignore bot messages + if (arg.Author.IsBot || arg.Author.IsWebhook) return; - int argPos = 0; - // Check if message starts with the command prefix - if (arg.HasStringPrefix("pk;", ref argPos) || arg.HasStringPrefix("pk!", ref argPos) || arg.HasMentionPrefix(_client.CurrentUser, ref argPos)) - { - // If it does, fetch the sender's system (because most commands need that) into the context, - // and start command execution - var system = await _connection.QueryFirstAsync<PKSystem>("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id }); - await _commands.ExecuteAsync(new PKCommandContext(_client, arg as SocketUserMessage, _connection, system), argPos, _services); - } - else - { - // If not, try proxying anyway - await _proxy.HandleMessageAsync(arg); + int argPos = 0; + // Check if message starts with the command prefix + if (arg.HasStringPrefix("pk;", ref argPos) || arg.HasStringPrefix("pk!", ref argPos) || arg.HasMentionPrefix(_client.CurrentUser, ref argPos)) + { + // If it does, fetch the sender's system (because most commands need that) into the context, + // and start command execution + var system = await _connection.QueryFirstAsync<PKSystem>("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id }); + await _commands.ExecuteAsync(new PKCommandContext(_client, arg as SocketUserMessage, _connection, system), argPos, _services); + + } + else + { + // If not, try proxying anyway + await _proxy.HandleMessageAsync(arg); + } + } catch (Exception e) { + // Generic exception handler + HandleRuntimeError(_arg, e); } } + + private void HandleRuntimeError(SocketMessage arg, Exception e) + { + Console.Error.WriteLine(e); + } } } \ No newline at end of file From c36cee6f28461fb16ae91d32688966ae4c9dde17 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sat, 20 Apr 2019 22:45:32 +0200 Subject: [PATCH 004/103] bot: create tables on first run --- PluralKit/Bot.cs | 7 ++--- PluralKit/Schema.cs | 64 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 PluralKit/Schema.cs diff --git a/PluralKit/Bot.cs b/PluralKit/Bot.cs index 02e12e63..c004900c 100644 --- a/PluralKit/Bot.cs +++ b/PluralKit/Bot.cs @@ -38,6 +38,7 @@ namespace PluralKit var connection = services.GetRequiredService<IDbConnection>() as NpgsqlConnection; connection.ConnectionString = Environment.GetEnvironmentVariable("PK_DATABASE_URI"); await connection.OpenAsync(); + await Schema.CreateTables(connection); Console.WriteLine("- Connecting to Discord..."); var client = services.GetRequiredService<IDiscordClient>() as DiscordSocketClient; @@ -127,9 +128,9 @@ namespace PluralKit { // If it does, fetch the sender's system (because most commands need that) into the context, // and start command execution - var system = await _connection.QueryFirstAsync<PKSystem>("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id }); - await _commands.ExecuteAsync(new PKCommandContext(_client, arg as SocketUserMessage, _connection, system), argPos, _services); - + // Note system may be null if user has no system, hence `OrDefault` + var system = await _connection.QueryFirstOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id }); + await _commands.ExecuteAsync(new PKCommandContext(_client, arg as SocketUserMessage, _connection, system), argPos, _services); } else { diff --git a/PluralKit/Schema.cs b/PluralKit/Schema.cs new file mode 100644 index 00000000..7dbc2f01 --- /dev/null +++ b/PluralKit/Schema.cs @@ -0,0 +1,64 @@ +using System.Data; +using System.Threading.Tasks; +using Dapper; + +namespace PluralKit { + public static class Schema { + public static async Task CreateTables(IDbConnection connection) { + await connection.ExecuteAsync(@"create table if not exists systems ( + id serial primary key, + hid char(5) unique not null, + name text, + description text, + tag text, + avatar_url text, + token text, + created timestamp not null default (current_timestamp at time zone 'utc'), + ui_tz text not null default 'UTC' + )"); + await connection.ExecuteAsync(@"create table if not exists members ( + id serial primary key, + hid char(5) unique not null, + system serial not null references systems(id) on delete cascade, + color char(6), + avatar_url text, + name text not null, + birthday date, + pronouns text, + description text, + prefix text, + suffix text, + created timestamp not null default (current_timestamp at time zone 'utc') + )"); + await connection.ExecuteAsync(@"create table if not exists accounts ( + uid bigint primary key, + system serial not null references systems(id) on delete cascade + )"); + await connection.ExecuteAsync(@"create table if not exists messages ( + mid bigint primary key, + channel bigint not null, + member serial not null references members(id) on delete cascade, + sender bigint not null + )"); + await connection.ExecuteAsync(@"create table if not exists switches ( + id serial primary key, + system serial not null references systems(id) on delete cascade, + timestamp timestamp not null default (current_timestamp at time zone 'utc') + )"); + await connection.ExecuteAsync(@"create table if not exists switch_members ( + id serial primary key, + switch serial not null references switches(id) on delete cascade, + member serial not null references members(id) on delete cascade + )"); + await connection.ExecuteAsync(@"create table if not exists webhooks ( + channel bigint primary key, + webhook bigint not null, + token text not null + )"); + await connection.ExecuteAsync(@"create table if not exists servers ( + id bigint primary key, + log_channel bigint + )"); + } + } +} \ No newline at end of file From 62cde789cbe19ea0fb9eb804eeea3a784cd7f51d Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sun, 21 Apr 2019 15:33:22 +0200 Subject: [PATCH 005/103] bot: split bot namespace, add system card, fix command handling --- PluralKit/{ => Bot}/Bot.cs | 4 +- .../{ => Bot}/Commands/SystemCommands.cs | 22 ++- PluralKit/Bot/Services/EmbedService.cs | 35 ++++ .../{ => Bot}/Services/LogChannelService.cs | 2 +- PluralKit/{ => Bot}/Services/ProxyService.cs | 2 +- PluralKit/Bot/Utils.cs | 171 ++++++++++++++++++ PluralKit/Stores.cs | 13 +- PluralKit/Utils.cs | 152 +--------------- 8 files changed, 242 insertions(+), 159 deletions(-) rename PluralKit/{ => Bot}/Bot.cs (98%) rename PluralKit/{ => Bot}/Commands/SystemCommands.cs (80%) create mode 100644 PluralKit/Bot/Services/EmbedService.cs rename PluralKit/{ => Bot}/Services/LogChannelService.cs (98%) rename PluralKit/{ => Bot}/Services/ProxyService.cs (99%) create mode 100644 PluralKit/Bot/Utils.cs 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<Bot>() .AddSingleton<CommandService>() + .AddSingleton<EmbedService>() .AddSingleton<LogChannelService>() .AddSingleton<ProxyService>() @@ -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<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; 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<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(); + } + } +} \ 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<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); + } +} \ 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<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, 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<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); } } \ No newline at end of file From 467719283fafaac918d2cbac76e5ccb5fe94cb77 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sun, 21 Apr 2019 19:11:05 +0200 Subject: [PATCH 006/103] bot: fix showing id instead of hid on system card --- PluralKit/Bot/Services/EmbedService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit/Bot/Services/EmbedService.cs b/PluralKit/Bot/Services/EmbedService.cs index 8ec77c76..f680a639 100644 --- a/PluralKit/Bot/Services/EmbedService.cs +++ b/PluralKit/Bot/Services/EmbedService.cs @@ -27,7 +27,7 @@ namespace PluralKit.Bot { .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`)"); + eb.AddField("Members", $"(see `pk;system {system.Hid} list` or `pk;system {system.Hid} list full`)"); // TODO: fronter return eb.Build(); } From 21b16667df463e1ef7620869680fd1214a7148a8 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 22 Apr 2019 17:10:18 +0200 Subject: [PATCH 007/103] bot: move log channel embed to embed service --- PluralKit/Bot/Services/EmbedService.cs | 12 +++++++++++- PluralKit/Bot/Services/LogChannelService.cs | 11 ++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/PluralKit/Bot/Services/EmbedService.cs b/PluralKit/Bot/Services/EmbedService.cs index f680a639..9c80a0f4 100644 --- a/PluralKit/Bot/Services/EmbedService.cs +++ b/PluralKit/Bot/Services/EmbedService.cs @@ -13,7 +13,7 @@ namespace PluralKit.Bot { this._client = client; } - public async Task<Embed> CreateEmbed(PKSystem system) { + public async Task<Embed> CreateSystemEmbed(PKSystem system) { var accounts = await _systems.GetLinkedAccountIds(system); // Fetch/render info for all accounts simultaneously @@ -31,5 +31,15 @@ namespace PluralKit.Bot { // TODO: fronter return eb.Build(); } + + public Embed CreateLoggedMessageEmbed(PKSystem system, PKMember member, IMessage message, IUser sender) { + // TODO: pronouns in ?-reacted response using this card + return new EmbedBuilder() + .WithAuthor($"#{message.Channel.Name}: {member.Name}", member.AvatarUrl) + .WithDescription(message.Content) + .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: ${sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: ${message.Id}") + .WithTimestamp(message.Timestamp) + .Build(); + } } } \ No newline at end of file diff --git a/PluralKit/Bot/Services/LogChannelService.cs b/PluralKit/Bot/Services/LogChannelService.cs index f10f5ee4..9f7b55c0 100644 --- a/PluralKit/Bot/Services/LogChannelService.cs +++ b/PluralKit/Bot/Services/LogChannelService.cs @@ -12,23 +12,20 @@ namespace PluralKit.Bot { class LogChannelService { private IDiscordClient _client; private IDbConnection _connection; + private EmbedService _embed; - public LogChannelService(IDiscordClient client, IDbConnection connection) + public LogChannelService(IDiscordClient client, IDbConnection connection, EmbedService embed) { this._client = client; this._connection = connection; + this._embed = embed; } public async Task LogMessage(PKSystem system, PKMember member, IMessage message, IUser sender) { var channel = await GetLogChannel((message.Channel as IGuildChannel).Guild); if (channel == null) return; - var embed = new EmbedBuilder() - .WithAuthor($"#{message.Channel.Name}: {member.Name}", member.AvatarUrl) - .WithDescription(message.Content) - .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: ${sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: ${message.Id}") - .WithTimestamp(message.Timestamp) - .Build(); + var embed = _embed.CreateLoggedMessageEmbed(system, member, message, sender); await channel.SendMessageAsync(text: message.GetJumpUrl(), embed: embed); } From b8065e2065ddc3e00123ad28b47e0ff9dede6ec3 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Thu, 25 Apr 2019 18:50:07 +0200 Subject: [PATCH 008/103] bot: periodically update game status --- PluralKit/Bot/Bot.cs | 13 +++++++++++-- PluralKit/Bot/Commands/SystemCommands.cs | 4 ++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/PluralKit/Bot/Bot.cs b/PluralKit/Bot/Bot.cs index 2a43b4d9..463077a9 100644 --- a/PluralKit/Bot/Bot.cs +++ b/PluralKit/Bot/Bot.cs @@ -2,6 +2,7 @@ using System; using System.Data; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using Dapper; using Discord; @@ -75,6 +76,7 @@ namespace PluralKit.Bot private CommandService _commands; private IDbConnection _connection; private ProxyService _proxy; + private Timer _updateTimer; public Bot(IServiceProvider services, IDiscordClient client, CommandService commands, IDbConnection connection, ProxyService proxy) { @@ -98,11 +100,18 @@ namespace PluralKit.Bot _client.MessageDeleted += _proxy.HandleMessageDeletedAsync; } - private Task Ready() + private async Task UpdatePeriodic() { + // Method called every 60 seconds + await _client.SetGameAsync($"pk;help | in {_client.Guilds.Count} servers"); + } + + private async Task Ready() + { + _updateTimer = new Timer((_) => Task.Run(this.UpdatePeriodic), null, 0, 60*1000); + Console.WriteLine($"Shard #{_client.ShardId} connected to {_client.Guilds.Sum(g => g.Channels.Count)} channels in {_client.Guilds.Count} guilds."); Console.WriteLine($"PluralKit started as {_client.CurrentUser.Username}#{_client.CurrentUser.Discriminator} ({_client.CurrentUser.Id})."); - return Task.CompletedTask; } private async Task CommandExecuted(Optional<CommandInfo> cmd, ICommandContext ctx, IResult _result) diff --git a/PluralKit/Bot/Commands/SystemCommands.cs b/PluralKit/Bot/Commands/SystemCommands.cs index 7ef9de53..cfe72dd9 100644 --- a/PluralKit/Bot/Commands/SystemCommands.cs +++ b/PluralKit/Bot/Commands/SystemCommands.cs @@ -21,7 +21,7 @@ namespace PluralKit.Bot.Commands if (system == null) system = Context.SenderSystem; if (system == null) return NO_SYSTEM_ERROR; - await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateEmbed(system)); + await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateSystemEmbed(system)); return PKResult.Success(); } @@ -33,7 +33,7 @@ namespace PluralKit.Bot.Commands 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(); } From b5d87290dbaa604e617439e1ca8d4d27e3f49115 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Fri, 26 Apr 2019 17:14:20 +0200 Subject: [PATCH 009/103] bot: finish tag setting command --- PluralKit/Bot/Bot.cs | 10 ++++- PluralKit/Bot/Commands/SystemCommands.cs | 55 +++++++++++++----------- PluralKit/Bot/Utils.cs | 42 +++++++++++++++--- PluralKit/Stores.cs | 6 +-- PluralKit/Utils.cs | 20 +++++++++ 5 files changed, 96 insertions(+), 37 deletions(-) diff --git a/PluralKit/Bot/Bot.cs b/PluralKit/Bot/Bot.cs index 463077a9..ae8d36ba 100644 --- a/PluralKit/Bot/Bot.cs +++ b/PluralKit/Bot/Bot.cs @@ -68,7 +68,6 @@ namespace PluralKit.Bot .AddSingleton<MessageStore>() .BuildServiceProvider(); } - class Bot { private IServiceProvider _services; @@ -117,7 +116,14 @@ namespace PluralKit.Bot private async Task CommandExecuted(Optional<CommandInfo> cmd, ICommandContext ctx, IResult _result) { if (!_result.IsSuccess) { - await ctx.Message.Channel.SendMessageAsync("\u274C " + _result.ErrorReason); + // If this is a PKError (ie. thrown deliberately), show user facing message + // If not, log as error + var pkError = (_result as ExecuteResult?)?.Exception as PKError; + if (pkError != null) { + await ctx.Message.Channel.SendMessageAsync("\u274C " + pkError.Message); + } else { + HandleRuntimeError(ctx.Message as SocketMessage, (_result as ExecuteResult?)?.Exception); + } } } diff --git a/PluralKit/Bot/Commands/SystemCommands.cs b/PluralKit/Bot/Commands/SystemCommands.cs index cfe72dd9..b72b44b2 100644 --- a/PluralKit/Bot/Commands/SystemCommands.cs +++ b/PluralKit/Bot/Commands/SystemCommands.cs @@ -1,4 +1,6 @@ using System; +using System.Linq; +using System.Runtime.Serialization; using System.Threading.Tasks; using Dapper; using Discord.Commands; @@ -13,67 +15,68 @@ namespace PluralKit.Bot.Commands 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."); + private PKError NO_SYSTEM_ERROR => new PKError($"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 PKError OTHER_SYSTEM_CONTEXT_ERROR => new PKError("You can only run this command on your own system."); [Command] - public async Task<RuntimeResult> Query(PKSystem system = null) { + public async Task Query(PKSystem system = null) { if (system == null) system = Context.SenderSystem; - if (system == null) return NO_SYSTEM_ERROR; + if (system == null) throw NO_SYSTEM_ERROR; await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateSystemEmbed(system)); - return PKResult.Success(); } [Command("new")] - public async Task<RuntimeResult> New([Remainder] string systemName = null) + public async Task New([Remainder] string systemName = null) { - 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."); + if (ContextEntity != null) throw OTHER_SYSTEM_CONTEXT_ERROR; + if (Context.SenderSystem != null) throw 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."); 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 (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)"); + public async Task Name([Remainder] string newSystemName = null) { + if (ContextEntity != null) throw OTHER_SYSTEM_CONTEXT_ERROR; + if (Context.SenderSystem == null) throw NO_SYSTEM_ERROR; + if (newSystemName != null && newSystemName.Length > 250) throw new PKError($"Your chosen system name is too long. ({newSystemName.Length} > 250 characters)"); Context.SenderSystem.Name = newSystemName; await Systems.Save(Context.SenderSystem); - return PKResult.Success(); + await Context.Channel.SendMessageAsync($"{Emojis.Success} System name {(newSystemName != null ? "changed" : "cleared")}."); } [Command("description")] - public async Task<RuntimeResult> Description([Remainder] string newDescription = null) { - 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)"); + public async Task Description([Remainder] string newDescription = null) { + if (ContextEntity != null) throw OTHER_SYSTEM_CONTEXT_ERROR; + if (Context.SenderSystem == null) throw NO_SYSTEM_ERROR; + if (newDescription != null && newDescription.Length > 1000) throw new PKError($"Your chosen description is too long. ({newDescription.Length} > 250 characters)"); Context.SenderSystem.Description = newDescription; await Systems.Save(Context.SenderSystem); - return PKResult.Success("uwu"); + await Context.Channel.SendMessageAsync($"{Emojis.Success} System description {(newDescription != null ? "changed" : "cleared")}."); } [Command("tag")] - public async Task<RuntimeResult> Tag([Remainder] string newTag = null) { - if (ContextEntity != null) return OTHER_SYSTEM_CONTEXT_ERROR; - if (Context.SenderSystem == null) return NO_SYSTEM_ERROR; + public async Task Tag([Remainder] string newTag = null) { + if (ContextEntity != null) throw OTHER_SYSTEM_CONTEXT_ERROR; + if (Context.SenderSystem == null) throw NO_SYSTEM_ERROR; + if (newTag.Length > 30) throw new PKError($"Your chosen description is too long. ({newTag.Length} > 30 characters)"); Context.SenderSystem.Tag = newTag; + // Check unproxyable messages *after* changing the tag (so it's seen in the method) but *before* we save to DB (so we can cancel) var unproxyableMembers = await Members.GetUnproxyableMembers(Context.SenderSystem); - //if (unproxyableMembers.Count > 0) { - throw new Exception("sdjsdflsdf"); - //} + if (unproxyableMembers.Count > 0) { + var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} Changing your system tag to '{newTag}' will result in the following members being unproxyable, since the tag would bring their name over 32 characters:\n**{string.Join(", ", unproxyableMembers.Select((m) => m.Name))}**\nDo you want to continue anyway?"); + if (!await Context.PromptYesNo(msg, TimeSpan.FromMinutes(5))) throw new PKError("Tag change cancelled."); + } await Systems.Save(Context.SenderSystem); - return PKResult.Success("uwu"); + await Context.Channel.SendMessageAsync($"{Emojis.Success} System tag {(newTag != null ? "changed" : "cleared")}."); } public override async Task<PKSystem> ReadContextParameterAsync(string value) diff --git a/PluralKit/Bot/Utils.cs b/PluralKit/Bot/Utils.cs index cf61901f..e81b7789 100644 --- a/PluralKit/Bot/Utils.cs +++ b/PluralKit/Bot/Utils.cs @@ -159,13 +159,43 @@ namespace PluralKit.Bot } } - public class PKResult : RuntimeResult - { - public PKResult(CommandError? error, string reason) : base(error, reason) - { + public static class ContextExt { + public static async Task<bool> PromptYesNo(this ICommandContext ctx, IMessage message, TimeSpan? timeout = null) { + await ctx.Message.AddReactionsAsync(new[] {new Emoji(Emojis.Success), new Emoji(Emojis.Error)}); + var reaction = await ctx.WaitForReaction(ctx.Message, message.Author, (r) => r.Emote.Name == Emojis.Success || r.Emote.Name == Emojis.Error); + return reaction.Emote.Name == Emojis.Success; } - public static RuntimeResult Error(string reason) => new PKResult(CommandError.Unsuccessful, reason); - public static RuntimeResult Success(string reason = null) => new PKResult(null, reason); + public static async Task<SocketReaction> WaitForReaction(this ICommandContext ctx, IUserMessage message, IUser user = null, Func<SocketReaction, bool> predicate = null, TimeSpan? timeout = null) { + var tcs = new TaskCompletionSource<SocketReaction>(); + + Task Inner(Cacheable<IUserMessage, ulong> _message, ISocketMessageChannel _channel, SocketReaction reaction) { + // Ignore reactions for different messages + if (message.Id != _message.Id) return Task.CompletedTask; + + // Ignore messages from other users if a user was defined + if (user != null && user.Id != reaction.UserId) return Task.CompletedTask; + + // Check the predicate, if true - accept the reaction + if (predicate?.Invoke(reaction) ?? true) { + tcs.SetResult(reaction); + } + return Task.CompletedTask; + } + + (ctx as BaseSocketClient).ReactionAdded += Inner; + + try { + return await (tcs.Task.TimeoutAfter(timeout)); + } finally { + (ctx as BaseSocketClient).ReactionAdded -= Inner; + } + } + } + class PKError : Exception + { + public PKError(string message) : base(message) + { + } } } \ No newline at end of file diff --git a/PluralKit/Stores.cs b/PluralKit/Stores.cs index 2b943387..e9cd4e9f 100644 --- a/PluralKit/Stores.cs +++ b/PluralKit/Stores.cs @@ -37,12 +37,12 @@ namespace PluralKit { } public async Task Save(PKSystem system) { - await conn.UpdateAsync(system); + await conn.ExecuteAsync("update systems set name = @Name, description = @Description, tag = @Tag, avatar_url = @AvatarUrl, token = @Token, ui_tz = @UiTz where id = @Id", system); } public async Task Delete(PKSystem system) { - await conn.DeleteAsync(system); - } + await conn.ExecuteAsync("delete from systems where id = @Id", system); + } public async Task<IEnumerable<ulong>> GetLinkedAccountIds(PKSystem system) { diff --git a/PluralKit/Utils.cs b/PluralKit/Utils.cs index 5190cee7..d5d2856f 100644 --- a/PluralKit/Utils.cs +++ b/PluralKit/Utils.cs @@ -1,5 +1,6 @@ using System; using System.Data; +using System.Threading; using System.Threading.Tasks; using Dapper; using Discord; @@ -28,5 +29,24 @@ namespace PluralKit if (str.Length < maxLength) return str; return str.Substring(0, maxLength - ellipsis.Length) + ellipsis; } + + public static async Task<TResult> TimeoutAfter<TResult>(this Task<TResult> task, TimeSpan? timeout) { + // https://stackoverflow.com/a/22078975 + using (var timeoutCancellationTokenSource = new CancellationTokenSource()) { + var completedTask = await Task.WhenAny(task, Task.Delay(timeout ?? TimeSpan.FromMilliseconds(-1), timeoutCancellationTokenSource.Token)); + if (completedTask == task) { + timeoutCancellationTokenSource.Cancel(); + return await task; // Very important in order to propagate exceptions + } else { + throw new TimeoutException(); + } + } + } + } + + public static class Emojis { + public static readonly string Warn = "\u26A0"; + public static readonly string Success = "\u2705"; + public static readonly string Error = "\u274C"; } } \ No newline at end of file From 876dcc0145b44ecc65329af1d296b5c1871f78eb Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Fri, 26 Apr 2019 18:15:25 +0200 Subject: [PATCH 010/103] bot: add system deletion command --- PluralKit/Bot/Bot.cs | 17 ++++++---- PluralKit/Bot/Commands/SystemCommands.cs | 15 ++++++++- PluralKit/Bot/Utils.cs | 42 +++++++++++++++--------- 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/PluralKit/Bot/Bot.cs b/PluralKit/Bot/Bot.cs index ae8d36ba..bc617b51 100644 --- a/PluralKit/Bot/Bot.cs +++ b/PluralKit/Bot/Bot.cs @@ -94,9 +94,12 @@ namespace PluralKit.Bot await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services); _client.Ready += Ready; - _client.MessageReceived += MessageReceived; - _client.ReactionAdded += _proxy.HandleReactionAddedAsync; - _client.MessageDeleted += _proxy.HandleMessageDeletedAsync; + + // Deliberately wrapping in an async function *without* awaiting, we don't want to "block" since this'd hold up the main loop + // These handlers return Task so we gotta be careful not to return the Task itself (which would then be awaited) - kinda weird design but eh + _client.MessageReceived += async (msg) => MessageReceived(msg); + _client.ReactionAdded += async (message, channel, reaction) => _proxy.HandleReactionAddedAsync(message, channel, reaction); + _client.MessageDeleted += async (message, channel) => _proxy.HandleMessageDeletedAsync(message, channel); } private async Task UpdatePeriodic() @@ -118,9 +121,11 @@ namespace PluralKit.Bot if (!_result.IsSuccess) { // If this is a PKError (ie. thrown deliberately), show user facing message // If not, log as error - var pkError = (_result as ExecuteResult?)?.Exception as PKError; - if (pkError != null) { - await ctx.Message.Channel.SendMessageAsync("\u274C " + pkError.Message); + var exception = (_result as ExecuteResult?)?.Exception; + if (exception is PKError) { + await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {exception.Message}"); + } else if (exception is TimeoutException) { + await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} Operation timed out. Try being faster"); } else { HandleRuntimeError(ctx.Message as SocketMessage, (_result as ExecuteResult?)?.Exception); } diff --git a/PluralKit/Bot/Commands/SystemCommands.cs b/PluralKit/Bot/Commands/SystemCommands.cs index b72b44b2..3f7175a8 100644 --- a/PluralKit/Bot/Commands/SystemCommands.cs +++ b/PluralKit/Bot/Commands/SystemCommands.cs @@ -30,7 +30,7 @@ namespace PluralKit.Bot.Commands public async Task New([Remainder] string systemName = null) { if (ContextEntity != null) throw OTHER_SYSTEM_CONTEXT_ERROR; - if (Context.SenderSystem != null) throw 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."); + if (Context.SenderSystem != null) throw 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`."); var system = await Systems.Create(systemName); await Systems.Link(system, Context.User.Id); @@ -79,6 +79,19 @@ namespace PluralKit.Bot.Commands await Context.Channel.SendMessageAsync($"{Emojis.Success} System tag {(newTag != null ? "changed" : "cleared")}."); } + [Command("delete")] + public async Task Delete() { + if (ContextEntity != null) throw OTHER_SYSTEM_CONTEXT_ERROR; + if (Context.SenderSystem == null) throw NO_SYSTEM_ERROR; + + var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} Are you sure you want to delete your system? If so, reply to this message with your system's ID (`{Context.SenderSystem.Hid}`).\n**Note: this action is permanent.**"); + var reply = await Context.AwaitMessage(Context.Channel, Context.User, timeout: TimeSpan.FromMinutes(1)); + if (reply.Content != Context.SenderSystem.Hid) throw new PKError($"System deletion cancelled. Note that you must reply with your system ID (`{Context.SenderSystem.Hid}`) *verbatim*."); + + await Systems.Delete(Context.SenderSystem); + await Context.Channel.SendMessageAsync($"{Emojis.Success} System deleted."); + } + public override async Task<PKSystem> ReadContextParameterAsync(string value) { var res = await new PKSystemTypeReader().ReadAsync(Context, value, _services); diff --git a/PluralKit/Bot/Utils.cs b/PluralKit/Bot/Utils.cs index e81b7789..38aaac3b 100644 --- a/PluralKit/Bot/Utils.cs +++ b/PluralKit/Bot/Utils.cs @@ -162,33 +162,43 @@ namespace PluralKit.Bot public static class ContextExt { public static async Task<bool> PromptYesNo(this ICommandContext ctx, IMessage message, TimeSpan? timeout = null) { await ctx.Message.AddReactionsAsync(new[] {new Emoji(Emojis.Success), new Emoji(Emojis.Error)}); - var reaction = await ctx.WaitForReaction(ctx.Message, message.Author, (r) => r.Emote.Name == Emojis.Success || r.Emote.Name == Emojis.Error); + var reaction = await ctx.AwaitReaction(ctx.Message, message.Author, (r) => r.Emote.Name == Emojis.Success || r.Emote.Name == Emojis.Error, timeout); return reaction.Emote.Name == Emojis.Success; } - public static async Task<SocketReaction> WaitForReaction(this ICommandContext ctx, IUserMessage message, IUser user = null, Func<SocketReaction, bool> predicate = null, TimeSpan? timeout = null) { + public static async Task<SocketReaction> AwaitReaction(this ICommandContext ctx, IUserMessage message, IUser user = null, Func<SocketReaction, bool> predicate = null, TimeSpan? timeout = null) { var tcs = new TaskCompletionSource<SocketReaction>(); - Task Inner(Cacheable<IUserMessage, ulong> _message, ISocketMessageChannel _channel, SocketReaction reaction) { - // Ignore reactions for different messages - if (message.Id != _message.Id) return Task.CompletedTask; - - // Ignore messages from other users if a user was defined - if (user != null && user.Id != reaction.UserId) return Task.CompletedTask; - - // Check the predicate, if true - accept the reaction - if (predicate?.Invoke(reaction) ?? true) { - tcs.SetResult(reaction); - } + if (message.Id != _message.Id) return Task.CompletedTask; // Ignore reactions for different messages + if (user != null && user.Id != reaction.UserId) return Task.CompletedTask; // Ignore messages from other users if a user was defined + if (predicate != null && !predicate.Invoke(reaction)) return Task.CompletedTask; // Check predicate + tcs.SetResult(reaction); return Task.CompletedTask; } - (ctx as BaseSocketClient).ReactionAdded += Inner; - + (ctx.Client as BaseSocketClient).ReactionAdded += Inner; try { return await (tcs.Task.TimeoutAfter(timeout)); } finally { - (ctx as BaseSocketClient).ReactionAdded -= Inner; + (ctx.Client as BaseSocketClient).ReactionAdded -= Inner; + } + } + + public static async Task<IUserMessage> AwaitMessage(this ICommandContext ctx, IMessageChannel channel, IUser user = null, Func<SocketMessage, bool> predicate = null, TimeSpan? timeout = null) { + var tcs = new TaskCompletionSource<IUserMessage>(); + Task Inner(SocketMessage msg) { + if (channel != msg.Channel) return Task.CompletedTask; // Ignore messages in a different channel + if (user != null && user != msg.Author) return Task.CompletedTask; // Ignore messages from other users + if (predicate != null && !predicate.Invoke(msg)) return Task.CompletedTask; // Check predicate + tcs.SetResult(msg as IUserMessage); + return Task.CompletedTask; + } + + (ctx.Client as BaseSocketClient).MessageReceived += Inner; + try { + return await (tcs.Task.TimeoutAfter(timeout)); + } finally { + (ctx.Client as BaseSocketClient).MessageReceived -= Inner; } } } From 8359df09e9043d3b492feabe63c58ca8183615f5 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sat, 27 Apr 2019 16:30:34 +0200 Subject: [PATCH 011/103] bot: add member creation command --- PluralKit/Bot/Bot.cs | 25 ++++++++----- PluralKit/Bot/Commands/MemberCommands.cs | 47 ++++++++++++++++++++++++ PluralKit/Bot/Commands/SystemCommands.cs | 29 +++++++-------- PluralKit/Bot/Utils.cs | 16 ++++++-- PluralKit/Stores.cs | 12 ++---- PluralKit/Utils.cs | 1 + 6 files changed, 95 insertions(+), 35 deletions(-) create mode 100644 PluralKit/Bot/Commands/MemberCommands.cs diff --git a/PluralKit/Bot/Bot.cs b/PluralKit/Bot/Bot.cs index bc617b51..287f5a0e 100644 --- a/PluralKit/Bot/Bot.cs +++ b/PluralKit/Bot/Bot.cs @@ -118,16 +118,23 @@ namespace PluralKit.Bot private async Task CommandExecuted(Optional<CommandInfo> cmd, ICommandContext ctx, IResult _result) { + // TODO: refactor this entire block, it's fugly. if (!_result.IsSuccess) { - // If this is a PKError (ie. thrown deliberately), show user facing message - // If not, log as error - var exception = (_result as ExecuteResult?)?.Exception; - if (exception is PKError) { - await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {exception.Message}"); - } else if (exception is TimeoutException) { - await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} Operation timed out. Try being faster"); - } else { - HandleRuntimeError(ctx.Message as SocketMessage, (_result as ExecuteResult?)?.Exception); + if (_result.Error == CommandError.Unsuccessful || _result.Error == CommandError.Exception) { + // If this is a PKError (ie. thrown deliberately), show user facing message + // If not, log as error + var exception = (_result as ExecuteResult?)?.Exception; + if (exception is PKError) { + await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {exception.Message}"); + } else if (exception is TimeoutException) { + await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} Operation timed out. Try being faster next time :)"); + } else { + HandleRuntimeError(ctx.Message as SocketMessage, (_result as ExecuteResult?)?.Exception); + } + } else if ((_result.Error == CommandError.BadArgCount || _result.Error == CommandError.MultipleMatches) && cmd.IsSpecified) { + await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}\n**Usage: **pk;{cmd.Value.Remarks}"); + } else if (_result.Error == CommandError.UnknownCommand || _result.Error == CommandError.UnmetPrecondition) { + await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}"); } } } diff --git a/PluralKit/Bot/Commands/MemberCommands.cs b/PluralKit/Bot/Commands/MemberCommands.cs new file mode 100644 index 00000000..e6246ede --- /dev/null +++ b/PluralKit/Bot/Commands/MemberCommands.cs @@ -0,0 +1,47 @@ +using System.Threading.Tasks; +using Discord.Commands; + +namespace PluralKit.Bot.Commands +{ + [Group("member")] + public class MemberCommands : ContextParameterModuleBase<PKMember> + { + public MemberStore Members { get; set; } + + public override string Prefix => "member"; + public override string ContextNoun => "member"; + + [Command("new")] + [Remarks("member new <name>")] + public async Task NewMember([Remainder] string memberName) { + if (Context.SenderSystem == null) Context.RaiseNoSystemError(); + if (ContextEntity != null) RaiseNoContextError(); + + // Warn if member name will be unproxyable (with/without tag) + var maxLength = Context.SenderSystem.Tag != null ? 32 - Context.SenderSystem.Tag.Length - 1 : 32; + if (memberName.Length > maxLength) { + var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} Member name too long ({memberName.Length} > {maxLength} characters), this member will be unproxyable. Do you want to create it anyway? (You can change the name later)"); + if (!await Context.PromptYesNo(msg)) throw new PKError("Member creation cancelled."); + } + + // Warn if there's already a member by this name + var existingMember = await Members.GetByName(Context.SenderSystem, memberName); + if (existingMember != null) { + var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.Name}\" (`{existingMember.Hid}`). Do you want to create another member with the same name?"); + if (!await Context.PromptYesNo(msg)) throw new PKError("Member creation cancelled."); + } + + // Create the member + var member = await Members.Create(Context.SenderSystem, memberName); + + await Context.Channel.SendMessageAsync($"{Emojis.Success} Member \"{memberName}\" (`{member.Hid}`) registered! Type `pk;help member` for a list of commands to edit this member."); + if (memberName.Contains(" ")) await Context.Channel.SendMessageAsync($"{Emojis.Note} Note that this member's name contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it."); + } + + public override async Task<PKMember> ReadContextParameterAsync(string value) + { + var res = await new PKMemberTypeReader().ReadAsync(Context, value, _services); + return res.IsSuccess ? res.BestMatch as PKMember : null; + } + } +} \ No newline at end of file diff --git a/PluralKit/Bot/Commands/SystemCommands.cs b/PluralKit/Bot/Commands/SystemCommands.cs index 3f7175a8..d8db5332 100644 --- a/PluralKit/Bot/Commands/SystemCommands.cs +++ b/PluralKit/Bot/Commands/SystemCommands.cs @@ -11,17 +11,16 @@ namespace PluralKit.Bot.Commands public class SystemCommands : ContextParameterModuleBase<PKSystem> { public override string Prefix => "system"; + public override string ContextNoun => "system"; + public SystemStore Systems {get; set;} public MemberStore Members {get; set;} public EmbedService EmbedService {get; set;} - private PKError NO_SYSTEM_ERROR => new PKError($"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 PKError OTHER_SYSTEM_CONTEXT_ERROR => new PKError("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) throw NO_SYSTEM_ERROR; + if (system == null) Context.RaiseNoSystemError(); await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateSystemEmbed(system)); } @@ -29,19 +28,19 @@ namespace PluralKit.Bot.Commands [Command("new")] public async Task New([Remainder] string systemName = null) { - if (ContextEntity != null) throw OTHER_SYSTEM_CONTEXT_ERROR; + if (ContextEntity != null) RaiseNoContextError(); if (Context.SenderSystem != null) throw 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`."); 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."); + await Context.Channel.SendMessageAsync($"{Emojis.Success} Your system has been created. Type `pk;system` to view it, and type `pk;help` for more information about commands you can use now."); } [Command("name")] public async Task Name([Remainder] string newSystemName = null) { - if (ContextEntity != null) throw OTHER_SYSTEM_CONTEXT_ERROR; - if (Context.SenderSystem == null) throw NO_SYSTEM_ERROR; + if (ContextEntity != null) RaiseNoContextError(); + if (Context.SenderSystem == null) Context.RaiseNoSystemError(); if (newSystemName != null && newSystemName.Length > 250) throw new PKError($"Your chosen system name is too long. ({newSystemName.Length} > 250 characters)"); Context.SenderSystem.Name = newSystemName; @@ -51,8 +50,8 @@ namespace PluralKit.Bot.Commands [Command("description")] public async Task Description([Remainder] string newDescription = null) { - if (ContextEntity != null) throw OTHER_SYSTEM_CONTEXT_ERROR; - if (Context.SenderSystem == null) throw NO_SYSTEM_ERROR; + if (ContextEntity != null) RaiseNoContextError(); + if (Context.SenderSystem == null) Context.RaiseNoSystemError(); if (newDescription != null && newDescription.Length > 1000) throw new PKError($"Your chosen description is too long. ({newDescription.Length} > 250 characters)"); Context.SenderSystem.Description = newDescription; @@ -62,8 +61,8 @@ namespace PluralKit.Bot.Commands [Command("tag")] public async Task Tag([Remainder] string newTag = null) { - if (ContextEntity != null) throw OTHER_SYSTEM_CONTEXT_ERROR; - if (Context.SenderSystem == null) throw NO_SYSTEM_ERROR; + if (ContextEntity != null) RaiseNoContextError(); + if (Context.SenderSystem == null) Context.RaiseNoSystemError(); if (newTag.Length > 30) throw new PKError($"Your chosen description is too long. ({newTag.Length} > 30 characters)"); Context.SenderSystem.Tag = newTag; @@ -72,7 +71,7 @@ namespace PluralKit.Bot.Commands var unproxyableMembers = await Members.GetUnproxyableMembers(Context.SenderSystem); if (unproxyableMembers.Count > 0) { var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} Changing your system tag to '{newTag}' will result in the following members being unproxyable, since the tag would bring their name over 32 characters:\n**{string.Join(", ", unproxyableMembers.Select((m) => m.Name))}**\nDo you want to continue anyway?"); - if (!await Context.PromptYesNo(msg, TimeSpan.FromMinutes(5))) throw new PKError("Tag change cancelled."); + if (!await Context.PromptYesNo(msg)) throw new PKError("Tag change cancelled."); } await Systems.Save(Context.SenderSystem); @@ -81,8 +80,8 @@ namespace PluralKit.Bot.Commands [Command("delete")] public async Task Delete() { - if (ContextEntity != null) throw OTHER_SYSTEM_CONTEXT_ERROR; - if (Context.SenderSystem == null) throw NO_SYSTEM_ERROR; + if (ContextEntity != null) RaiseNoContextError(); + if (Context.SenderSystem == null) Context.RaiseNoSystemError(); var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} Are you sure you want to delete your system? If so, reply to this message with your system's ID (`{Context.SenderSystem.Hid}`).\n**Note: this action is permanent.**"); var reply = await Context.AwaitMessage(Context.Channel, Context.User, timeout: TimeSpan.FromMinutes(1)); diff --git a/PluralKit/Bot/Utils.cs b/PluralKit/Bot/Utils.cs index 38aaac3b..4b262df7 100644 --- a/PluralKit/Bot/Utils.cs +++ b/PluralKit/Bot/Utils.cs @@ -112,6 +112,11 @@ namespace PluralKit.Bot public void SetContextEntity(object entity) { _entity = entity; } + + public void RaiseNoSystemError() + { + throw new PKError($"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 {User.Mention}` from that account to link it here."); + } } public abstract class ContextParameterModuleBase<T> : ModuleBase<PKCommandContext> where T: class @@ -120,6 +125,7 @@ namespace PluralKit.Bot public CommandService _commands { get; set; } public abstract string Prefix { get; } + public abstract string ContextNoun { get; } public abstract Task<T> ReadContextParameterAsync(string value); public T ContextEntity => Context.GetContextEntity<T>(); @@ -141,6 +147,10 @@ namespace PluralKit.Bot cb.AddParameter<string>("rest", (pb) => pb.WithDefault("").WithIsRemainder(true)); }); } + + public void RaiseNoContextError() { + throw new PKError($"You can only run this command on your own {ContextNoun}."); + } } public class ContextParameterFallbackPreconditionAttribute : PreconditionAttribute @@ -160,9 +170,9 @@ namespace PluralKit.Bot } public static class ContextExt { - public static async Task<bool> PromptYesNo(this ICommandContext ctx, IMessage message, TimeSpan? timeout = null) { - await ctx.Message.AddReactionsAsync(new[] {new Emoji(Emojis.Success), new Emoji(Emojis.Error)}); - var reaction = await ctx.AwaitReaction(ctx.Message, message.Author, (r) => r.Emote.Name == Emojis.Success || r.Emote.Name == Emojis.Error, timeout); + public static async Task<bool> PromptYesNo(this ICommandContext ctx, IUserMessage message, TimeSpan? timeout = null) { + await message.AddReactionsAsync(new[] {new Emoji(Emojis.Success), new Emoji(Emojis.Error)}); + var reaction = await ctx.AwaitReaction(message, ctx.User, (r) => r.Emote.Name == Emojis.Success || r.Emote.Name == Emojis.Error, timeout ?? TimeSpan.FromMinutes(1)); return reaction.Emote.Name == Emojis.Success; } diff --git a/PluralKit/Stores.cs b/PluralKit/Stores.cs index e9cd4e9f..ec9ba5e6 100644 --- a/PluralKit/Stores.cs +++ b/PluralKit/Stores.cs @@ -60,7 +60,7 @@ namespace PluralKit { public async Task<PKMember> Create(PKSystem system, string name) { // TODO: handle collision var hid = Utils.GenerateHid(); - return await conn.QuerySingleAsync("insert into members (hid, system, name) values (@Hid, @SystemId, @Name) returning *", new { + return await conn.QuerySingleAsync<PKMember>("insert into members (hid, system, name) values (@Hid, @SystemId, @Name) returning *", new { Hid = hid, SystemID = system.Id, Name = name @@ -68,15 +68,11 @@ namespace PluralKit { } public async Task<PKMember> GetByHid(string hid) { - return await conn.QuerySingleAsync("select * from members where hid = @Hid", new { Hid = hid.ToLower() }); + return await conn.QuerySingleOrDefaultAsync<PKMember>("select * from members where hid = @Hid", new { Hid = hid.ToLower() }); } - public async Task<PKMember> GetByName(string name) { - return await conn.QuerySingleAsync("select * from members where lower(name) = lower(@Name)", new { Name = name }); - } - - public async Task<PKMember> GetByNameConstrained(PKSystem system, string name) { - return await conn.QuerySingleAsync("select * from members where lower(name) = @Name and system = @SystemID", new { Name = name, SystemID = system.Id }); + public async Task<PKMember> GetByName(PKSystem system, string name) { + return await conn.QuerySingleOrDefaultAsync<PKMember>("select * from members where lower(name) = @Name and system = @SystemID", new { Name = name, SystemID = system.Id }); } public async Task<ICollection<PKMember>> GetUnproxyableMembers(PKSystem system) { diff --git a/PluralKit/Utils.cs b/PluralKit/Utils.cs index d5d2856f..09a52eff 100644 --- a/PluralKit/Utils.cs +++ b/PluralKit/Utils.cs @@ -48,5 +48,6 @@ namespace PluralKit public static readonly string Warn = "\u26A0"; public static readonly string Success = "\u2705"; public static readonly string Error = "\u274C"; + public static readonly string Note = "\u2757"; } } \ No newline at end of file From 02dfa35a81ef416512bec2da9cffc41de006a7be Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 29 Apr 2019 17:42:09 +0200 Subject: [PATCH 012/103] bot: add system member list commands --- PluralKit/Bot/Bot.cs | 55 ++++++------ PluralKit/Bot/Commands/SystemCommands.cs | 45 +++++++++- PluralKit/Bot/ContextUtils.cs | 101 +++++++++++++++++++++++ PluralKit/Bot/Utils.cs | 46 +---------- PluralKit/Models.cs | 22 ++++- PluralKit/Stores.cs | 2 +- PluralKit/TaskUtils.cs | 28 +++++++ PluralKit/Utils.cs | 13 --- 8 files changed, 219 insertions(+), 93 deletions(-) create mode 100644 PluralKit/Bot/ContextUtils.cs create mode 100644 PluralKit/TaskUtils.cs diff --git a/PluralKit/Bot/Bot.cs b/PluralKit/Bot/Bot.cs index 287f5a0e..41591916 100644 --- a/PluralKit/Bot/Bot.cs +++ b/PluralKit/Bot/Bot.cs @@ -97,9 +97,9 @@ namespace PluralKit.Bot // Deliberately wrapping in an async function *without* awaiting, we don't want to "block" since this'd hold up the main loop // These handlers return Task so we gotta be careful not to return the Task itself (which would then be awaited) - kinda weird design but eh - _client.MessageReceived += async (msg) => MessageReceived(msg); - _client.ReactionAdded += async (message, channel, reaction) => _proxy.HandleReactionAddedAsync(message, channel, reaction); - _client.MessageDeleted += async (message, channel) => _proxy.HandleMessageDeletedAsync(message, channel); + _client.MessageReceived += async (msg) => MessageReceived(msg).CatchException(HandleRuntimeError); + _client.ReactionAdded += async (message, channel, reaction) => _proxy.HandleReactionAddedAsync(message, channel, reaction).CatchException(HandleRuntimeError); + _client.MessageDeleted += async (message, channel) => _proxy.HandleMessageDeletedAsync(message, channel).CatchException(HandleRuntimeError); } private async Task UpdatePeriodic() @@ -110,7 +110,7 @@ namespace PluralKit.Bot private async Task Ready() { - _updateTimer = new Timer((_) => Task.Run(this.UpdatePeriodic), null, 0, 60*1000); + _updateTimer = new Timer((_) => this.UpdatePeriodic(), null, 0, 60*1000); Console.WriteLine($"Shard #{_client.ShardId} connected to {_client.Guilds.Sum(g => g.Channels.Count)} channels in {_client.Guilds.Count} guilds."); Console.WriteLine($"PluralKit started as {_client.CurrentUser.Username}#{_client.CurrentUser.Discriminator} ({_client.CurrentUser.Id})."); @@ -129,7 +129,7 @@ namespace PluralKit.Bot } else if (exception is TimeoutException) { await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} Operation timed out. Try being faster next time :)"); } else { - HandleRuntimeError(ctx.Message as SocketMessage, (_result as ExecuteResult?)?.Exception); + HandleRuntimeError((_result as ExecuteResult?)?.Exception); } } else if ((_result.Error == CommandError.BadArgCount || _result.Error == CommandError.MultipleMatches) && cmd.IsSpecified) { await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}\n**Usage: **pk;{cmd.Value.Remarks}"); @@ -141,36 +141,31 @@ namespace PluralKit.Bot private async Task MessageReceived(SocketMessage _arg) { - try { - // Ignore system messages (member joined, message pinned, etc) - var arg = _arg as SocketUserMessage; - if (arg == null) return; + // Ignore system messages (member joined, message pinned, etc) + var arg = _arg as SocketUserMessage; + if (arg == null) return; - // Ignore bot messages - if (arg.Author.IsBot || arg.Author.IsWebhook) return; + // Ignore bot messages + if (arg.Author.IsBot || arg.Author.IsWebhook) return; - int argPos = 0; - // Check if message starts with the command prefix - if (arg.HasStringPrefix("pk;", ref argPos) || arg.HasStringPrefix("pk!", ref argPos) || arg.HasMentionPrefix(_client.CurrentUser, ref argPos)) - { - // If it does, fetch the sender's system (because most commands need that) into the context, - // and start command execution - // Note system may be null if user has no system, hence `OrDefault` - var system = await _connection.QueryFirstOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id }); - await _commands.ExecuteAsync(new PKCommandContext(_client, arg as SocketUserMessage, _connection, system), argPos, _services); - } - else - { - // If not, try proxying anyway - await _proxy.HandleMessageAsync(arg); - } - } catch (Exception e) { - // Generic exception handler - HandleRuntimeError(_arg, e); + int argPos = 0; + // Check if message starts with the command prefix + if (arg.HasStringPrefix("pk;", ref argPos) || arg.HasStringPrefix("pk!", ref argPos) || arg.HasMentionPrefix(_client.CurrentUser, ref argPos)) + { + // If it does, fetch the sender's system (because most commands need that) into the context, + // and start command execution + // Note system may be null if user has no system, hence `OrDefault` + var system = await _connection.QueryFirstOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id }); + await _commands.ExecuteAsync(new PKCommandContext(_client, arg as SocketUserMessage, _connection, system), argPos, _services); + } + else + { + // If not, try proxying anyway + await _proxy.HandleMessageAsync(arg); } } - private void HandleRuntimeError(SocketMessage arg, Exception e) + private void HandleRuntimeError(Exception e) { Console.Error.WriteLine(e); } diff --git a/PluralKit/Bot/Commands/SystemCommands.cs b/PluralKit/Bot/Commands/SystemCommands.cs index d8db5332..960f782e 100644 --- a/PluralKit/Bot/Commands/SystemCommands.cs +++ b/PluralKit/Bot/Commands/SystemCommands.cs @@ -33,7 +33,6 @@ namespace PluralKit.Bot.Commands var system = await Systems.Create(systemName); await Systems.Link(system, Context.User.Id); - await Context.Channel.SendMessageAsync($"{Emojis.Success} Your system has been created. Type `pk;system` to view it, and type `pk;help` for more information about commands you can use now."); } @@ -91,6 +90,50 @@ namespace PluralKit.Bot.Commands await Context.Channel.SendMessageAsync($"{Emojis.Success} System deleted."); } + [Group("list")] + public class SystemListCommands: ModuleBase<PKCommandContext> { + public MemberStore Members { get; set; } + + [Command] + public async Task MemberShortList() { + var system = Context.GetContextEntity<PKSystem>() ?? Context.SenderSystem; + if (system == null) Context.RaiseNoSystemError(); + + var members = await Members.GetBySystem(system); + var embedTitle = system.Name != null ? $"Members of {system.Name} (`{system.Hid}`)" : $"Members of `{system.Hid}`"; + await Context.Paginate<PKMember>( + members.ToList(), + 25, + embedTitle, + (eb, ms) => eb.Description = string.Join("\n", ms.Select((m) => $"[`{m.Hid}`] **{m.Name}** *({m.Prefix ?? ""}text{m.Suffix ?? ""})*")) + ); + } + + [Command("full")] + [Alias("big", "details", "long")] + public async Task MemberLongList() { + var system = Context.GetContextEntity<PKSystem>() ?? Context.SenderSystem; + if (system == null) Context.RaiseNoSystemError(); + + var members = await Members.GetBySystem(system); + var embedTitle = system.Name != null ? $"Members of {system.Name} (`{system.Hid}`)" : $"Members of `{system.Hid}`"; + await Context.Paginate<PKMember>( + members.ToList(), + 10, + embedTitle, + (eb, ms) => { + foreach (var member in ms) { + var profile = $"**ID**: {member.Hid}"; + if (member.Pronouns != null) profile += $"\n**Pronouns**: {member.Pronouns}"; + if (member.Birthday != null) profile += $"\n**Birthdate**: {member.BirthdayString}"; + if (member.Description != null) profile += $"\n\n{member.Description}"; + eb.AddField(member.Name, profile); + } + } + ); + } + } + public override async Task<PKSystem> ReadContextParameterAsync(string value) { var res = await new PKSystemTypeReader().ReadAsync(Context, value, _services); diff --git a/PluralKit/Bot/ContextUtils.cs b/PluralKit/Bot/ContextUtils.cs new file mode 100644 index 00000000..1b8fa9f5 --- /dev/null +++ b/PluralKit/Bot/ContextUtils.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.Commands; +using Discord.WebSocket; + +namespace PluralKit.Bot { + public static class ContextUtils { + public static async Task<bool> PromptYesNo(this ICommandContext ctx, IUserMessage message, TimeSpan? timeout = null) { + await message.AddReactionsAsync(new[] {new Emoji(Emojis.Success), new Emoji(Emojis.Error)}); + var reaction = await ctx.AwaitReaction(message, ctx.User, (r) => r.Emote.Name == Emojis.Success || r.Emote.Name == Emojis.Error, timeout ?? TimeSpan.FromMinutes(1)); + return reaction.Emote.Name == Emojis.Success; + } + + public static async Task<SocketReaction> AwaitReaction(this ICommandContext ctx, IUserMessage message, IUser user = null, Func<SocketReaction, bool> predicate = null, TimeSpan? timeout = null) { + var tcs = new TaskCompletionSource<SocketReaction>(); + Task Inner(Cacheable<IUserMessage, ulong> _message, ISocketMessageChannel _channel, SocketReaction reaction) { + if (message.Id != _message.Id) return Task.CompletedTask; // Ignore reactions for different messages + if (user != null && user.Id != reaction.UserId) return Task.CompletedTask; // Ignore messages from other users if a user was defined + if (predicate != null && !predicate.Invoke(reaction)) return Task.CompletedTask; // Check predicate + tcs.SetResult(reaction); + return Task.CompletedTask; + } + + (ctx.Client as BaseSocketClient).ReactionAdded += Inner; + try { + return await (tcs.Task.TimeoutAfter(timeout)); + } finally { + (ctx.Client as BaseSocketClient).ReactionAdded -= Inner; + } + } + + public static async Task<IUserMessage> AwaitMessage(this ICommandContext ctx, IMessageChannel channel, IUser user = null, Func<SocketMessage, bool> predicate = null, TimeSpan? timeout = null) { + var tcs = new TaskCompletionSource<IUserMessage>(); + Task Inner(SocketMessage msg) { + if (channel != msg.Channel) return Task.CompletedTask; // Ignore messages in a different channel + if (user != null && user != msg.Author) return Task.CompletedTask; // Ignore messages from other users + if (predicate != null && !predicate.Invoke(msg)) return Task.CompletedTask; // Check predicate + + (ctx.Client as BaseSocketClient).MessageReceived -= Inner; + tcs.SetResult(msg as IUserMessage); + + return Task.CompletedTask; + } + + (ctx.Client as BaseSocketClient).MessageReceived += Inner; + return await (tcs.Task.TimeoutAfter(timeout)); + } + + public static async Task Paginate<T>(this ICommandContext ctx, ICollection<T> items, int itemsPerPage, string title, Action<EmbedBuilder, IEnumerable<T>> renderer) { + var pageCount = (items.Count / itemsPerPage) + 1; + Embed MakeEmbedForPage(int page) { + var eb = new EmbedBuilder(); + eb.Title = pageCount > 1 ? $"[{page+1}/{pageCount}] {title}" : title; + renderer(eb, items.Skip(page*itemsPerPage).Take(itemsPerPage)); + return eb.Build(); + } + + var msg = await ctx.Channel.SendMessageAsync(embed: MakeEmbedForPage(0)); + var botEmojis = new[] { new Emoji("\u23EA"), new Emoji("\u2B05"), new Emoji("\u27A1"), new Emoji("\u23E9"), new Emoji(Emojis.Error) }; + await msg.AddReactionsAsync(botEmojis); + + try { + var currentPage = 0; + while (true) { + var reaction = await ctx.AwaitReaction(msg, ctx.User, timeout: TimeSpan.FromMinutes(5)); + + // Increment/decrement page counter based on which reaction was clicked + if (reaction.Emote.Name == "\u23EA") currentPage = 0; // << + if (reaction.Emote.Name == "\u2B05") currentPage = (currentPage - 1) % pageCount; // < + if (reaction.Emote.Name == "\u27A1") currentPage = (currentPage + 1) % pageCount; // > + if (reaction.Emote.Name == "\u23E9") currentPage = pageCount - 1; // >> + if (reaction.Emote.Name == Emojis.Error) break; // X + + // If we can, remove the user's reaction (so they can press again quickly) + if (await ctx.HasPermission(ChannelPermission.ManageMessages) && reaction.User.IsSpecified) await msg.RemoveReactionAsync(reaction.Emote, reaction.User.Value); + + // Edit the embed with the new page + await msg.ModifyAsync((mp) => mp.Embed = MakeEmbedForPage(currentPage)); + } + } catch (TimeoutException) { + // "escape hatch", clean up as if we hit X + } + + if (await ctx.HasPermission(ChannelPermission.ManageMessages)) await msg.RemoveAllReactionsAsync(); + else await msg.RemoveReactionsAsync(ctx.Client.CurrentUser, botEmojis); + } + + public static async Task<ChannelPermissions> Permissions(this ICommandContext ctx) { + if (ctx.Channel is IGuildChannel) { + var gu = await ctx.Guild.GetCurrentUserAsync(); + return gu.GetPermissions(ctx.Channel as IGuildChannel); + } + return ChannelPermissions.DM; + } + + public static async Task<bool> HasPermission(this ICommandContext ctx, ChannelPermission permission) => (await Permissions(ctx)).Has(permission); + } +} \ No newline at end of file diff --git a/PluralKit/Bot/Utils.cs b/PluralKit/Bot/Utils.cs index 4b262df7..71f0c6c8 100644 --- a/PluralKit/Bot/Utils.cs +++ b/PluralKit/Bot/Utils.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Data; +using System.Linq; using System.Threading.Tasks; using Dapper; using Discord; @@ -168,50 +170,6 @@ namespace PluralKit.Bot } } } - - public static class ContextExt { - public static async Task<bool> PromptYesNo(this ICommandContext ctx, IUserMessage message, TimeSpan? timeout = null) { - await message.AddReactionsAsync(new[] {new Emoji(Emojis.Success), new Emoji(Emojis.Error)}); - var reaction = await ctx.AwaitReaction(message, ctx.User, (r) => r.Emote.Name == Emojis.Success || r.Emote.Name == Emojis.Error, timeout ?? TimeSpan.FromMinutes(1)); - return reaction.Emote.Name == Emojis.Success; - } - - public static async Task<SocketReaction> AwaitReaction(this ICommandContext ctx, IUserMessage message, IUser user = null, Func<SocketReaction, bool> predicate = null, TimeSpan? timeout = null) { - var tcs = new TaskCompletionSource<SocketReaction>(); - Task Inner(Cacheable<IUserMessage, ulong> _message, ISocketMessageChannel _channel, SocketReaction reaction) { - if (message.Id != _message.Id) return Task.CompletedTask; // Ignore reactions for different messages - if (user != null && user.Id != reaction.UserId) return Task.CompletedTask; // Ignore messages from other users if a user was defined - if (predicate != null && !predicate.Invoke(reaction)) return Task.CompletedTask; // Check predicate - tcs.SetResult(reaction); - return Task.CompletedTask; - } - - (ctx.Client as BaseSocketClient).ReactionAdded += Inner; - try { - return await (tcs.Task.TimeoutAfter(timeout)); - } finally { - (ctx.Client as BaseSocketClient).ReactionAdded -= Inner; - } - } - - public static async Task<IUserMessage> AwaitMessage(this ICommandContext ctx, IMessageChannel channel, IUser user = null, Func<SocketMessage, bool> predicate = null, TimeSpan? timeout = null) { - var tcs = new TaskCompletionSource<IUserMessage>(); - Task Inner(SocketMessage msg) { - if (channel != msg.Channel) return Task.CompletedTask; // Ignore messages in a different channel - if (user != null && user != msg.Author) return Task.CompletedTask; // Ignore messages from other users - if (predicate != null && !predicate.Invoke(msg)) return Task.CompletedTask; // Check predicate - tcs.SetResult(msg as IUserMessage); - return Task.CompletedTask; - } - - (ctx.Client as BaseSocketClient).MessageReceived += Inner; - try { - return await (tcs.Task.TimeoutAfter(timeout)); - } finally { - (ctx.Client as BaseSocketClient).MessageReceived -= Inner; - } - } - } class PKError : Exception { public PKError(string message) : base(message) diff --git a/PluralKit/Models.cs b/PluralKit/Models.cs index cc985abe..0afe078e 100644 --- a/PluralKit/Models.cs +++ b/PluralKit/Models.cs @@ -1,9 +1,11 @@ using System; using Dapper.Contrib.Extensions; -namespace PluralKit { +namespace PluralKit +{ [Table("systems")] - public class PKSystem { + public class PKSystem + { [Key] public int Id { get; set; } public string Hid { get; set; } @@ -17,18 +19,30 @@ namespace PluralKit { } [Table("members")] - public class PKMember { + public class PKMember + { public int Id { get; set; } public string Hid { get; set; } public int System { get; set; } public string Color { get; set; } public string AvatarUrl { get; set; } public string Name { get; set; } - public DateTime Date { get; set; } + public DateTime? Birthday { get; set; } public string Pronouns { get; set; } public string Description { get; set; } public string Prefix { get; set; } public string Suffix { get; set; } public DateTime Created { get; set; } + + /// Returns a formatted string representing the member's birthday, taking into account that a year of "0001" is hidden + public string BirthdayString + { + get + { + if (Birthday == null) return null; + if (Birthday?.Year == 1) return Birthday?.ToString("MMMM dd"); + return Birthday?.ToString("MMMM dd, yyyy"); + } + } } } \ No newline at end of file diff --git a/PluralKit/Stores.cs b/PluralKit/Stores.cs index ec9ba5e6..bab94dad 100644 --- a/PluralKit/Stores.cs +++ b/PluralKit/Stores.cs @@ -125,7 +125,7 @@ namespace PluralKit { msg.System = system; msg.Member = member; return msg; - }, new { Id = id })).First(); + }, new { Id = id })).FirstOrDefault(); } public async Task Delete(ulong id) { diff --git a/PluralKit/TaskUtils.cs b/PluralKit/TaskUtils.cs new file mode 100644 index 00000000..f48e7d73 --- /dev/null +++ b/PluralKit/TaskUtils.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace PluralKit { + public static class TaskUtils { + public static async Task CatchException(this Task task, Action<Exception> handler) { + try { + await task; + } catch (Exception e) { + handler(e); + } + } + + public static async Task<TResult> TimeoutAfter<TResult>(this Task<TResult> task, TimeSpan? timeout) { + // https://stackoverflow.com/a/22078975 + using (var timeoutCancellationTokenSource = new CancellationTokenSource()) { + var completedTask = await Task.WhenAny(task, Task.Delay(timeout ?? TimeSpan.FromMilliseconds(-1), timeoutCancellationTokenSource.Token)); + if (completedTask == task) { + timeoutCancellationTokenSource.Cancel(); + return await task; // Very important in order to propagate exceptions + } else { + throw new TimeoutException(); + } + } + } + } +} \ No newline at end of file diff --git a/PluralKit/Utils.cs b/PluralKit/Utils.cs index 09a52eff..75b5864f 100644 --- a/PluralKit/Utils.cs +++ b/PluralKit/Utils.cs @@ -29,19 +29,6 @@ namespace PluralKit if (str.Length < maxLength) return str; return str.Substring(0, maxLength - ellipsis.Length) + ellipsis; } - - public static async Task<TResult> TimeoutAfter<TResult>(this Task<TResult> task, TimeSpan? timeout) { - // https://stackoverflow.com/a/22078975 - using (var timeoutCancellationTokenSource = new CancellationTokenSource()) { - var completedTask = await Task.WhenAny(task, Task.Delay(timeout ?? TimeSpan.FromMilliseconds(-1), timeoutCancellationTokenSource.Token)); - if (completedTask == task) { - timeoutCancellationTokenSource.Cancel(); - return await task; // Very important in order to propagate exceptions - } else { - throw new TimeoutException(); - } - } - } } public static class Emojis { From ff78639ac0daa9863ce3b95c7c0a42b50570bdd6 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 29 Apr 2019 17:44:20 +0200 Subject: [PATCH 013/103] bot: add usage for commands --- PluralKit/Bot/Commands/SystemCommands.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/PluralKit/Bot/Commands/SystemCommands.cs b/PluralKit/Bot/Commands/SystemCommands.cs index 960f782e..664640d4 100644 --- a/PluralKit/Bot/Commands/SystemCommands.cs +++ b/PluralKit/Bot/Commands/SystemCommands.cs @@ -26,6 +26,7 @@ namespace PluralKit.Bot.Commands } [Command("new")] + [Remarks("system new <name>")] public async Task New([Remainder] string systemName = null) { if (ContextEntity != null) RaiseNoContextError(); @@ -37,6 +38,7 @@ namespace PluralKit.Bot.Commands } [Command("name")] + [Remarks("system name <name>")] public async Task Name([Remainder] string newSystemName = null) { if (ContextEntity != null) RaiseNoContextError(); if (Context.SenderSystem == null) Context.RaiseNoSystemError(); @@ -48,6 +50,7 @@ namespace PluralKit.Bot.Commands } [Command("description")] + [Remarks("system description <description>")] public async Task Description([Remainder] string newDescription = null) { if (ContextEntity != null) RaiseNoContextError(); if (Context.SenderSystem == null) Context.RaiseNoSystemError(); @@ -59,6 +62,7 @@ namespace PluralKit.Bot.Commands } [Command("tag")] + [Remarks("system tag <tag>")] public async Task Tag([Remainder] string newTag = null) { if (ContextEntity != null) RaiseNoContextError(); if (Context.SenderSystem == null) Context.RaiseNoSystemError(); @@ -78,6 +82,7 @@ namespace PluralKit.Bot.Commands } [Command("delete")] + [Remarks("system delete")] public async Task Delete() { if (ContextEntity != null) RaiseNoContextError(); if (Context.SenderSystem == null) Context.RaiseNoSystemError(); @@ -95,6 +100,7 @@ namespace PluralKit.Bot.Commands public MemberStore Members { get; set; } [Command] + [Remarks("system [system] list")] public async Task MemberShortList() { var system = Context.GetContextEntity<PKSystem>() ?? Context.SenderSystem; if (system == null) Context.RaiseNoSystemError(); @@ -111,6 +117,7 @@ namespace PluralKit.Bot.Commands [Command("full")] [Alias("big", "details", "long")] + [Remarks("system [system] list full")] public async Task MemberLongList() { var system = Context.GetContextEntity<PKSystem>() ?? Context.SenderSystem; if (system == null) Context.RaiseNoSystemError(); From 9a5a5ce34fe45ec2fd0e91bdc9e2176eb7cbe03f Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 29 Apr 2019 19:43:09 +0200 Subject: [PATCH 014/103] bot: add member renaming command --- PluralKit/Bot/Commands/MemberCommands.cs | 41 ++++++++++++++++++++---- PluralKit/Bot/Commands/SystemCommands.cs | 23 ++++++------- PluralKit/Bot/Errors.cs | 9 ++++++ PluralKit/Bot/Preconditions.cs | 34 ++++++++++++++++++++ PluralKit/Bot/Utils.cs | 37 +++++++++------------ PluralKit/Models.cs | 2 ++ PluralKit/Stores.cs | 7 ++-- 7 files changed, 108 insertions(+), 45 deletions(-) create mode 100644 PluralKit/Bot/Errors.cs create mode 100644 PluralKit/Bot/Preconditions.cs diff --git a/PluralKit/Bot/Commands/MemberCommands.cs b/PluralKit/Bot/Commands/MemberCommands.cs index e6246ede..380b45f1 100644 --- a/PluralKit/Bot/Commands/MemberCommands.cs +++ b/PluralKit/Bot/Commands/MemberCommands.cs @@ -1,3 +1,4 @@ +using System; using System.Threading.Tasks; using Discord.Commands; @@ -13,31 +14,57 @@ namespace PluralKit.Bot.Commands [Command("new")] [Remarks("member new <name>")] + [MustHaveSystem] public async Task NewMember([Remainder] string memberName) { - if (Context.SenderSystem == null) Context.RaiseNoSystemError(); - if (ContextEntity != null) RaiseNoContextError(); - // Warn if member name will be unproxyable (with/without tag) - var maxLength = Context.SenderSystem.Tag != null ? 32 - Context.SenderSystem.Tag.Length - 1 : 32; - if (memberName.Length > maxLength) { - var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} Member name too long ({memberName.Length} > {maxLength} characters), this member will be unproxyable. Do you want to create it anyway? (You can change the name later)"); + if (memberName.Length > Context.SenderSystem.MaxMemberNameLength) { + var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} Member name too long ({memberName.Length} > {Context.SenderSystem.MaxMemberNameLength} characters), this member will be unproxyable. Do you want to create it anyway? (You can change the name later)"); if (!await Context.PromptYesNo(msg)) throw new PKError("Member creation cancelled."); } // Warn if there's already a member by this name var existingMember = await Members.GetByName(Context.SenderSystem, memberName); if (existingMember != null) { - var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.Name}\" (`{existingMember.Hid}`). Do you want to create another member with the same name?"); + var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.Name}\" (with ID `{existingMember.Hid}`). Do you want to create another member with the same name?"); if (!await Context.PromptYesNo(msg)) throw new PKError("Member creation cancelled."); } // Create the member var member = await Members.Create(Context.SenderSystem, memberName); + // Send confirmation and space hint await Context.Channel.SendMessageAsync($"{Emojis.Success} Member \"{memberName}\" (`{member.Hid}`) registered! Type `pk;help member` for a list of commands to edit this member."); if (memberName.Contains(" ")) await Context.Channel.SendMessageAsync($"{Emojis.Note} Note that this member's name contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it."); } + [Command("rename")] + [Alias("name", "changename", "setname")] + [Remarks("member <member> rename <newname>")] + [MustPassOwnMember] + public async Task RenameMember([Remainder] string newName) { + // TODO: this method is pretty much a 1:1 copy/paste of the above creation method, find a way to clean? + + // Warn if member name will be unproxyable (with/without tag) + if (newName.Length > Context.SenderSystem.MaxMemberNameLength) { + var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} New member name too long ({newName.Length} > {Context.SenderSystem.MaxMemberNameLength} characters), this member will be unproxyable. Do you want to change it anyway?"); + if (!await Context.PromptYesNo(msg)) throw new PKError("Member renaming cancelled."); + } + + // Warn if there's already a member by this name + var existingMember = await Members.GetByName(Context.SenderSystem, newName); + if (existingMember != null) { + var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.Name}\" (`{existingMember.Hid}`). Do you want to rename this member to that name too?"); + if (!await Context.PromptYesNo(msg)) throw new PKError("Member renaming cancelled."); + } + + // Rename the mebmer + ContextEntity.Name = newName; + await Members.Save(ContextEntity); + + await Context.Channel.SendMessageAsync($"{Emojis.Success} Member renamed."); + if (newName.Contains(" ")) await Context.Channel.SendMessageAsync($"{Emojis.Note} Note that this member's name now contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it."); + } + public override async Task<PKMember> ReadContextParameterAsync(string value) { var res = await new PKMemberTypeReader().ReadAsync(Context, value, _services); diff --git a/PluralKit/Bot/Commands/SystemCommands.cs b/PluralKit/Bot/Commands/SystemCommands.cs index 664640d4..e60f6dbf 100644 --- a/PluralKit/Bot/Commands/SystemCommands.cs +++ b/PluralKit/Bot/Commands/SystemCommands.cs @@ -20,7 +20,7 @@ namespace PluralKit.Bot.Commands [Command] public async Task Query(PKSystem system = null) { if (system == null) system = Context.SenderSystem; - if (system == null) Context.RaiseNoSystemError(); + if (system == null) throw Errors.NotOwnSystemError; await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateSystemEmbed(system)); } @@ -29,8 +29,8 @@ namespace PluralKit.Bot.Commands [Remarks("system new <name>")] public async Task New([Remainder] string systemName = null) { - if (ContextEntity != null) RaiseNoContextError(); - if (Context.SenderSystem != null) throw 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`."); + if (ContextEntity != null) throw Errors.NotOwnSystemError; + if (Context.SenderSystem != null) throw Errors.NoSystemError; var system = await Systems.Create(systemName); await Systems.Link(system, Context.User.Id); @@ -39,9 +39,8 @@ namespace PluralKit.Bot.Commands [Command("name")] [Remarks("system name <name>")] + [MustHaveSystem] public async Task Name([Remainder] string newSystemName = null) { - if (ContextEntity != null) RaiseNoContextError(); - if (Context.SenderSystem == null) Context.RaiseNoSystemError(); if (newSystemName != null && newSystemName.Length > 250) throw new PKError($"Your chosen system name is too long. ({newSystemName.Length} > 250 characters)"); Context.SenderSystem.Name = newSystemName; @@ -51,9 +50,8 @@ namespace PluralKit.Bot.Commands [Command("description")] [Remarks("system description <description>")] + [MustHaveSystem] public async Task Description([Remainder] string newDescription = null) { - if (ContextEntity != null) RaiseNoContextError(); - if (Context.SenderSystem == null) Context.RaiseNoSystemError(); if (newDescription != null && newDescription.Length > 1000) throw new PKError($"Your chosen description is too long. ({newDescription.Length} > 250 characters)"); Context.SenderSystem.Description = newDescription; @@ -63,9 +61,8 @@ namespace PluralKit.Bot.Commands [Command("tag")] [Remarks("system tag <tag>")] + [MustHaveSystem] public async Task Tag([Remainder] string newTag = null) { - if (ContextEntity != null) RaiseNoContextError(); - if (Context.SenderSystem == null) Context.RaiseNoSystemError(); if (newTag.Length > 30) throw new PKError($"Your chosen description is too long. ({newTag.Length} > 30 characters)"); Context.SenderSystem.Tag = newTag; @@ -83,10 +80,8 @@ namespace PluralKit.Bot.Commands [Command("delete")] [Remarks("system delete")] + [MustHaveSystem] public async Task Delete() { - if (ContextEntity != null) RaiseNoContextError(); - if (Context.SenderSystem == null) Context.RaiseNoSystemError(); - var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} Are you sure you want to delete your system? If so, reply to this message with your system's ID (`{Context.SenderSystem.Hid}`).\n**Note: this action is permanent.**"); var reply = await Context.AwaitMessage(Context.Channel, Context.User, timeout: TimeSpan.FromMinutes(1)); if (reply.Content != Context.SenderSystem.Hid) throw new PKError($"System deletion cancelled. Note that you must reply with your system ID (`{Context.SenderSystem.Hid}`) *verbatim*."); @@ -103,7 +98,7 @@ namespace PluralKit.Bot.Commands [Remarks("system [system] list")] public async Task MemberShortList() { var system = Context.GetContextEntity<PKSystem>() ?? Context.SenderSystem; - if (system == null) Context.RaiseNoSystemError(); + if (system == null) throw Errors.NoSystemError; var members = await Members.GetBySystem(system); var embedTitle = system.Name != null ? $"Members of {system.Name} (`{system.Hid}`)" : $"Members of `{system.Hid}`"; @@ -120,7 +115,7 @@ namespace PluralKit.Bot.Commands [Remarks("system [system] list full")] public async Task MemberLongList() { var system = Context.GetContextEntity<PKSystem>() ?? Context.SenderSystem; - if (system == null) Context.RaiseNoSystemError(); + if (system == null) throw Errors.NoSystemError; var members = await Members.GetBySystem(system); var embedTitle = system.Name != null ? $"Members of {system.Name} (`{system.Hid}`)" : $"Members of `{system.Hid}`"; diff --git a/PluralKit/Bot/Errors.cs b/PluralKit/Bot/Errors.cs new file mode 100644 index 00000000..38396e67 --- /dev/null +++ b/PluralKit/Bot/Errors.cs @@ -0,0 +1,9 @@ +namespace PluralKit.Bot { + public static class Errors { + 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 NoSystemError => new PKError("You do not have a system registered with PluralKit. To create one, type `pk;system new`."); + public static PKError ExistinSystemError => 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."); + } +} \ No newline at end of file diff --git a/PluralKit/Bot/Preconditions.cs b/PluralKit/Bot/Preconditions.cs new file mode 100644 index 00000000..f08cb674 --- /dev/null +++ b/PluralKit/Bot/Preconditions.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading.Tasks; +using Discord.Commands; + +namespace PluralKit.Bot { + class MustHaveSystem : PreconditionAttribute + { + public override async Task<PreconditionResult> CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + var c = context as PKCommandContext; + if (c == null) return PreconditionResult.FromError("Must be called on a PKCommandContext (should never happen!)"); + if (c.SenderSystem == null) return PreconditionResult.FromError(Errors.NoSystemError); + return PreconditionResult.FromSuccess(); + } + } + + class MustPassOwnMember : PreconditionAttribute + { + public override async Task<PreconditionResult> CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + // OK when: + // - Sender has a system + // - Sender passes a member as a context parameter + // - Sender owns said member + + var c = context as PKCommandContext; + if (c == null) + if (c.SenderSystem == null) return PreconditionResult.FromError(Errors.NoSystemError); + if (c.GetContextEntity<PKMember>() == null) return PreconditionResult.FromError(Errors.MissingMemberError); + if (c.GetContextEntity<PKMember>().System != c.SenderSystem.Id) return PreconditionResult.FromError(Errors.NotOwnMemberError); + return PreconditionResult.FromSuccess(); + } + } +} \ No newline at end of file diff --git a/PluralKit/Bot/Utils.cs b/PluralKit/Bot/Utils.cs index 71f0c6c8..79823af1 100644 --- a/PluralKit/Bot/Utils.cs +++ b/PluralKit/Bot/Utils.cs @@ -114,11 +114,6 @@ namespace PluralKit.Bot public void SetContextEntity(object entity) { _entity = entity; } - - public void RaiseNoSystemError() - { - throw new PKError($"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 {User.Mention}` from that account to link it here."); - } } public abstract class ContextParameterModuleBase<T> : ModuleBase<PKCommandContext> where T: class @@ -138,42 +133,42 @@ namespace PluralKit.Bot // 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); + pkCtx.SetContextEntity(param[0] as T); 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.AddPrecondition(new MustNotHaveContextPrecondition()); + cb.AddParameter<T>("contextValue", (pb) => pb.WithDefault("")); cb.AddParameter<string>("rest", (pb) => pb.WithDefault("").WithIsRemainder(true)); }); } - - public void RaiseNoContextError() { - throw new PKError($"You can only run this command on your own {ContextNoun}."); - } } - public class ContextParameterFallbackPreconditionAttribute : PreconditionAttribute + public class MustNotHaveContextPrecondition : PreconditionAttribute { - public ContextParameterFallbackPreconditionAttribute() + public MustNotHaveContextPrecondition() { } 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(""); - } + if ((context as PKCommandContext)?.GetContextEntity<object>() == null) return PreconditionResult.FromSuccess(); + return PreconditionResult.FromError("(should not be seen)"); } } - class PKError : Exception + + public class PKError : Exception { public PKError(string message) : base(message) { } } + + public class PKSyntaxError : PKError + { + public PKSyntaxError(string message) : base(message) + { + } + } } \ No newline at end of file diff --git a/PluralKit/Models.cs b/PluralKit/Models.cs index 0afe078e..3f17c5aa 100644 --- a/PluralKit/Models.cs +++ b/PluralKit/Models.cs @@ -16,6 +16,8 @@ namespace PluralKit public string Token { get; set; } public DateTime Created { get; set; } public string UiTz { get; set; } + + public int MaxMemberNameLength => Tag != null ? 32 - Tag.Length - 1 : 32; } [Table("members")] diff --git a/PluralKit/Stores.cs b/PluralKit/Stores.cs index bab94dad..e81032ce 100644 --- a/PluralKit/Stores.cs +++ b/PluralKit/Stores.cs @@ -72,7 +72,8 @@ namespace PluralKit { } public async Task<PKMember> GetByName(PKSystem system, string name) { - return await conn.QuerySingleOrDefaultAsync<PKMember>("select * from members where lower(name) = @Name and system = @SystemID", new { Name = name, SystemID = system.Id }); + // QueryFirst, since members can (in rare cases) share names + return await conn.QueryFirstOrDefaultAsync<PKMember>("select * from members where lower(name) = @Name and system = @SystemID", new { Name = name, SystemID = system.Id }); } public async Task<ICollection<PKMember>> GetUnproxyableMembers(PKSystem system) { @@ -88,11 +89,11 @@ namespace PluralKit { } public async Task Save(PKMember member) { - await conn.UpdateAsync(member); + await conn.ExecuteAsync("update members set name = @Name, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, prefix = @Prefix, suffix = @Suffix where id = @Id", member); } public async Task Delete(PKMember member) { - await conn.DeleteAsync(member); + await conn.ExecuteAsync("delete from members where id = @Id", member); } } From c3599358556bc71b7a49b44061a21e60799f11bd Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 29 Apr 2019 19:44:17 +0200 Subject: [PATCH 015/103] bot: sort member list alphabetically --- PluralKit/Bot/Commands/SystemCommands.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PluralKit/Bot/Commands/SystemCommands.cs b/PluralKit/Bot/Commands/SystemCommands.cs index e60f6dbf..baa29225 100644 --- a/PluralKit/Bot/Commands/SystemCommands.cs +++ b/PluralKit/Bot/Commands/SystemCommands.cs @@ -103,7 +103,7 @@ namespace PluralKit.Bot.Commands var members = await Members.GetBySystem(system); var embedTitle = system.Name != null ? $"Members of {system.Name} (`{system.Hid}`)" : $"Members of `{system.Hid}`"; await Context.Paginate<PKMember>( - members.ToList(), + members.OrderBy(m => m.Name).ToList(), 25, embedTitle, (eb, ms) => eb.Description = string.Join("\n", ms.Select((m) => $"[`{m.Hid}`] **{m.Name}** *({m.Prefix ?? ""}text{m.Suffix ?? ""})*")) @@ -120,7 +120,7 @@ namespace PluralKit.Bot.Commands var members = await Members.GetBySystem(system); var embedTitle = system.Name != null ? $"Members of {system.Name} (`{system.Hid}`)" : $"Members of `{system.Hid}`"; await Context.Paginate<PKMember>( - members.ToList(), + members.OrderBy(m => m.Name).ToList(), 10, embedTitle, (eb, ms) => { From 6be9a6a89a8bd0b1a33abeb2321fbee4dfc4527a Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 29 Apr 2019 19:52:07 +0200 Subject: [PATCH 016/103] bot: only show proxy tags in list when present --- PluralKit/Bot/Commands/SystemCommands.cs | 18 +++++++++++------- PluralKit/Models.cs | 3 +++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/PluralKit/Bot/Commands/SystemCommands.cs b/PluralKit/Bot/Commands/SystemCommands.cs index baa29225..ba1b1cb3 100644 --- a/PluralKit/Bot/Commands/SystemCommands.cs +++ b/PluralKit/Bot/Commands/SystemCommands.cs @@ -106,7 +106,10 @@ namespace PluralKit.Bot.Commands members.OrderBy(m => m.Name).ToList(), 25, embedTitle, - (eb, ms) => eb.Description = string.Join("\n", ms.Select((m) => $"[`{m.Hid}`] **{m.Name}** *({m.Prefix ?? ""}text{m.Suffix ?? ""})*")) + (eb, ms) => eb.Description = string.Join("\n", ms.Select((m) => { + if (m.HasProxyTags) return $"[`{m.Hid}`] **{m.Name}** *({m.ProxyString})*"; + return $"[`{m.Hid}`] **{m.Name}**"; + })) ); } @@ -124,12 +127,13 @@ namespace PluralKit.Bot.Commands 10, embedTitle, (eb, ms) => { - foreach (var member in ms) { - var profile = $"**ID**: {member.Hid}"; - if (member.Pronouns != null) profile += $"\n**Pronouns**: {member.Pronouns}"; - if (member.Birthday != null) profile += $"\n**Birthdate**: {member.BirthdayString}"; - if (member.Description != null) profile += $"\n\n{member.Description}"; - eb.AddField(member.Name, profile); + foreach (var m in ms) { + var profile = $"**ID**: {m.Hid}"; + if (m.Pronouns != null) profile += $"\n**Pronouns**: {m.Pronouns}"; + if (m.Birthday != null) profile += $"\n**Birthdate**: {m.BirthdayString}"; + if (m.Prefix != null || m.Suffix != null) profile += $"\n**Proxy tags**: {m.ProxyString}"; + if (m.Description != null) profile += $"\n\n{m.Description}"; + eb.AddField(m.Name, profile); } } ); diff --git a/PluralKit/Models.cs b/PluralKit/Models.cs index 3f17c5aa..5afacfa5 100644 --- a/PluralKit/Models.cs +++ b/PluralKit/Models.cs @@ -46,5 +46,8 @@ namespace PluralKit return Birthday?.ToString("MMMM dd, yyyy"); } } + + public bool HasProxyTags => Prefix != null || Suffix != null; + public string ProxyString => $"{Prefix ?? ""}text{Suffix ?? ""}"; } } \ No newline at end of file From bf8387cf525b0f95c8515adb3de8f36c54c9494e Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 29 Apr 2019 20:21:16 +0200 Subject: [PATCH 017/103] bot: add invite command --- PluralKit/Bot/Commands/MiscCommands.cs | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 PluralKit/Bot/Commands/MiscCommands.cs diff --git a/PluralKit/Bot/Commands/MiscCommands.cs b/PluralKit/Bot/Commands/MiscCommands.cs new file mode 100644 index 00000000..e44d9f39 --- /dev/null +++ b/PluralKit/Bot/Commands/MiscCommands.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Discord; +using Discord.Commands; + +namespace PluralKit.Bot { + public class MiscCommands: ModuleBase<PKCommandContext> { + [Command("invite")] + [Remarks("invite")] + public async Task Invite() { + var info = await Context.Client.GetApplicationInfoAsync(); + + var permissions = new GuildPermissions( + addReactions: true, + attachFiles: true, + embedLinks: true, + manageMessages: true, + manageWebhooks: true, + readMessageHistory: true, + sendMessages: true + ); + + // TODO: allow customization of invite ID + var invite = $"https://discordapp.com/oauth2/authorize?client_id={info.Id}&scope=bot&permissions={permissions.RawValue}"; + await Context.Channel.SendMessageAsync($"{Emojis.Success} Use this link to add PluralKit to your server:\n<{invite}>"); + } + } +} \ No newline at end of file From 6fe3154a10b3fbd0217a3fb6be12681cd15940ec Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 29 Apr 2019 20:28:53 +0200 Subject: [PATCH 018/103] bot: refactor length limits to use constants --- PluralKit/Bot/Commands/MemberCommands.cs | 6 ++++++ PluralKit/Bot/Commands/SystemCommands.cs | 6 +++--- PluralKit/Bot/Errors.cs | 7 +++++++ PluralKit/Bot/Limits.cs | 8 ++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 PluralKit/Bot/Limits.cs diff --git a/PluralKit/Bot/Commands/MemberCommands.cs b/PluralKit/Bot/Commands/MemberCommands.cs index 380b45f1..0f6e2d58 100644 --- a/PluralKit/Bot/Commands/MemberCommands.cs +++ b/PluralKit/Bot/Commands/MemberCommands.cs @@ -16,6 +16,9 @@ namespace PluralKit.Bot.Commands [Remarks("member new <name>")] [MustHaveSystem] public async Task NewMember([Remainder] string memberName) { + // Hard name length cap + if (memberName.Length > Limits.MaxMemberNameLength) throw Errors.MemberNameTooLongError(memberName.Length); + // Warn if member name will be unproxyable (with/without tag) if (memberName.Length > Context.SenderSystem.MaxMemberNameLength) { var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} Member name too long ({memberName.Length} > {Context.SenderSystem.MaxMemberNameLength} characters), this member will be unproxyable. Do you want to create it anyway? (You can change the name later)"); @@ -44,6 +47,9 @@ namespace PluralKit.Bot.Commands public async Task RenameMember([Remainder] string newName) { // TODO: this method is pretty much a 1:1 copy/paste of the above creation method, find a way to clean? + // Hard name length cap + if (newName.Length > Limits.MaxMemberNameLength) throw Errors.MemberNameTooLongError(newName.Length); + // Warn if member name will be unproxyable (with/without tag) if (newName.Length > Context.SenderSystem.MaxMemberNameLength) { var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} New member name too long ({newName.Length} > {Context.SenderSystem.MaxMemberNameLength} characters), this member will be unproxyable. Do you want to change it anyway?"); diff --git a/PluralKit/Bot/Commands/SystemCommands.cs b/PluralKit/Bot/Commands/SystemCommands.cs index ba1b1cb3..851c5968 100644 --- a/PluralKit/Bot/Commands/SystemCommands.cs +++ b/PluralKit/Bot/Commands/SystemCommands.cs @@ -41,7 +41,7 @@ namespace PluralKit.Bot.Commands [Remarks("system name <name>")] [MustHaveSystem] public async Task Name([Remainder] string newSystemName = null) { - if (newSystemName != null && newSystemName.Length > 250) throw new PKError($"Your chosen system name is too long. ({newSystemName.Length} > 250 characters)"); + if (newSystemName != null && newSystemName.Length > Limits.MaxSystemNameLength) throw Errors.SystemNameTooLongError(newSystemName.Length); Context.SenderSystem.Name = newSystemName; await Systems.Save(Context.SenderSystem); @@ -52,7 +52,7 @@ namespace PluralKit.Bot.Commands [Remarks("system description <description>")] [MustHaveSystem] public async Task Description([Remainder] string newDescription = null) { - if (newDescription != null && newDescription.Length > 1000) throw new PKError($"Your chosen description is too long. ({newDescription.Length} > 250 characters)"); + if (newDescription != null && newDescription.Length > Limits.MaxDescriptionLength) throw Errors.DescriptionTooLongError(newDescription.Length); Context.SenderSystem.Description = newDescription; await Systems.Save(Context.SenderSystem); @@ -63,7 +63,7 @@ namespace PluralKit.Bot.Commands [Remarks("system tag <tag>")] [MustHaveSystem] public async Task Tag([Remainder] string newTag = null) { - if (newTag.Length > 30) throw new PKError($"Your chosen description is too long. ({newTag.Length} > 30 characters)"); + if (newTag.Length > Limits.MaxSystemTagLength) throw Errors.SystemNameTooLongError(newTag.Length); Context.SenderSystem.Tag = newTag; diff --git a/PluralKit/Bot/Errors.cs b/PluralKit/Bot/Errors.cs index 38396e67..12938bf1 100644 --- a/PluralKit/Bot/Errors.cs +++ b/PluralKit/Bot/Errors.cs @@ -1,9 +1,16 @@ namespace PluralKit.Bot { public static class Errors { + // TODO: is returning constructed errors and throwing them at call site a good idea, or should these be methods that insta-throw instead? + 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 NoSystemError => new PKError("You do not have a system registered with PluralKit. To create one, type `pk;system new`."); public static PKError ExistinSystemError => 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."); + + public static PKError SystemNameTooLongError(int nameLength) => new PKError($"Your chosen system name is too long ({nameLength}/{Limits.MaxSystemNameLength} characters)."); + public static PKError SystemTagTooLongError(int nameLength) => new PKError($"Your chosen system tag is too long ({nameLength}/{Limits.MaxSystemTagLength} characters)."); + public static PKError DescriptionTooLongError(int nameLength) => new PKError($"Your chosen description is too long ({nameLength}/{Limits.MaxDescriptionLength} characters)."); + public static PKError MemberNameTooLongError(int nameLength) => new PKError($"Your chosen member name is too long ({nameLength}/{Limits.MaxMemberNameLength} characters)."); } } \ No newline at end of file diff --git a/PluralKit/Bot/Limits.cs b/PluralKit/Bot/Limits.cs new file mode 100644 index 00000000..e0fe0b29 --- /dev/null +++ b/PluralKit/Bot/Limits.cs @@ -0,0 +1,8 @@ +namespace PluralKit.Bot { + public static class Limits { + public static readonly int MaxSystemNameLength = 100; + public static readonly int MaxSystemTagLength = 31; + public static readonly int MaxDescriptionLength = 1000; + public static readonly int MaxMemberNameLength = 50; + } +} \ No newline at end of file From e55b7f6b718afe7aee3817ce82d2b7ae8d2377dd Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 29 Apr 2019 20:33:21 +0200 Subject: [PATCH 019/103] bot: add member description command --- PluralKit/Bot/Commands/MemberCommands.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/PluralKit/Bot/Commands/MemberCommands.cs b/PluralKit/Bot/Commands/MemberCommands.cs index 0f6e2d58..e7d43fad 100644 --- a/PluralKit/Bot/Commands/MemberCommands.cs +++ b/PluralKit/Bot/Commands/MemberCommands.cs @@ -71,6 +71,19 @@ namespace PluralKit.Bot.Commands if (newName.Contains(" ")) await Context.Channel.SendMessageAsync($"{Emojis.Note} Note that this member's name now contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it."); } + [Command("description")] + [Alias("info", "bio", "text")] + [Remarks("member <member> description <description")] + [MustPassOwnMember] + public async Task MemberDescription([Remainder] string description = null) { + if (description.Length > Limits.MaxDescriptionLength) throw Errors.DescriptionTooLongError(description.Length); + + ContextEntity.Description = description; + await Members.Save(ContextEntity); + + await Context.Channel.SendMessageAsync($"{Emojis.Success} Member description {(description == null ? "cleared" : "changed")}."); + } + public override async Task<PKMember> ReadContextParameterAsync(string value) { var res = await new PKMemberTypeReader().ReadAsync(Context, value, _services); From 23d85923948f13eeecb25a9aa8f0534d0139f09f Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 29 Apr 2019 20:36:09 +0200 Subject: [PATCH 020/103] bot: add member pronouns command --- PluralKit/Bot/Commands/MemberCommands.cs | 13 +++++++++++++ PluralKit/Bot/Errors.cs | 11 ++++++----- PluralKit/Bot/Limits.cs | 1 + 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/PluralKit/Bot/Commands/MemberCommands.cs b/PluralKit/Bot/Commands/MemberCommands.cs index e7d43fad..3339bd78 100644 --- a/PluralKit/Bot/Commands/MemberCommands.cs +++ b/PluralKit/Bot/Commands/MemberCommands.cs @@ -84,6 +84,19 @@ namespace PluralKit.Bot.Commands await Context.Channel.SendMessageAsync($"{Emojis.Success} Member description {(description == null ? "cleared" : "changed")}."); } + [Command("pronouns")] + [Alias("pronoun")] + [Remarks("member <member> pronouns <pronouns")] + [MustPassOwnMember] + public async Task MemberPronouns([Remainder] string pronouns = null) { + if (pronouns.Length > Limits.MaxPronounsLength) throw Errors.MemberPronounsTooLongError(pronouns.Length); + + ContextEntity.Pronouns = pronouns; + await Members.Save(ContextEntity); + + await Context.Channel.SendMessageAsync($"{Emojis.Success} Member pronouns {(pronouns == null ? "cleared" : "changed")}."); + } + public override async Task<PKMember> ReadContextParameterAsync(string value) { var res = await new PKMemberTypeReader().ReadAsync(Context, value, _services); diff --git a/PluralKit/Bot/Errors.cs b/PluralKit/Bot/Errors.cs index 12938bf1..7508fce9 100644 --- a/PluralKit/Bot/Errors.cs +++ b/PluralKit/Bot/Errors.cs @@ -1,16 +1,17 @@ namespace PluralKit.Bot { public static class Errors { // TODO: is returning constructed errors and throwing them at call site a good idea, or should these be methods that insta-throw instead? - + 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 NoSystemError => new PKError("You do not have a system registered with PluralKit. To create one, type `pk;system new`."); public static PKError ExistinSystemError => 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."); - public static PKError SystemNameTooLongError(int nameLength) => new PKError($"Your chosen system name is too long ({nameLength}/{Limits.MaxSystemNameLength} characters)."); - public static PKError SystemTagTooLongError(int nameLength) => new PKError($"Your chosen system tag is too long ({nameLength}/{Limits.MaxSystemTagLength} characters)."); - public static PKError DescriptionTooLongError(int nameLength) => new PKError($"Your chosen description is too long ({nameLength}/{Limits.MaxDescriptionLength} characters)."); - public static PKError MemberNameTooLongError(int nameLength) => new PKError($"Your chosen member name is too long ({nameLength}/{Limits.MaxMemberNameLength} characters)."); + public static PKError SystemNameTooLongError(int length) => new PKError($"System name too long ({length}/{Limits.MaxSystemNameLength} characters)."); + public static PKError SystemTagTooLongError(int length) => new PKError($"System tag too long ({length}/{Limits.MaxSystemTagLength} characters)."); + public static PKError DescriptionTooLongError(int length) => new PKError($"Description too long ({length}/{Limits.MaxDescriptionLength} characters)."); + public static PKError MemberNameTooLongError(int length) => new PKError($"Member name too long ({length}/{Limits.MaxMemberNameLength} characters)."); + public static PKError MemberPronounsTooLongError(int length) => new PKError($"Member pronouns too long ({length}/{Limits.MaxMemberNameLength} characters)."); } } \ No newline at end of file diff --git a/PluralKit/Bot/Limits.cs b/PluralKit/Bot/Limits.cs index e0fe0b29..9971bcfc 100644 --- a/PluralKit/Bot/Limits.cs +++ b/PluralKit/Bot/Limits.cs @@ -4,5 +4,6 @@ namespace PluralKit.Bot { public static readonly int MaxSystemTagLength = 31; public static readonly int MaxDescriptionLength = 1000; public static readonly int MaxMemberNameLength = 50; + public static readonly int MaxPronounsLength = 100; } } \ No newline at end of file From c5d2b7c25162a0b19209ef870d7460ef3a933ae3 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 8 May 2019 00:06:27 +0200 Subject: [PATCH 021/103] refactor project structure --- {PluralKit/Bot => PluralKit.Bot}/Bot.cs | 0 .../Commands/MemberCommands.cs | 0 .../Commands/MiscCommands.cs | 0 .../Commands/SystemCommands.cs | 0 .../Bot => PluralKit.Bot}/ContextUtils.cs | 0 {PluralKit/Bot => PluralKit.Bot}/Errors.cs | 0 {PluralKit/Bot => PluralKit.Bot}/Limits.cs | 0 PluralKit.Bot/PluralKit.Bot.csproj | 18 +++++++++++++++ .../Bot => PluralKit.Bot}/Preconditions.cs | 0 .../Services/EmbedService.cs | 0 .../Services/LogChannelService.cs | 0 .../Services/ProxyService.cs | 0 {PluralKit/Bot => PluralKit.Bot}/Utils.cs | 0 {PluralKit => PluralKit.Core}/Models.cs | 0 PluralKit.Core/PluralKit.Core.csproj | 13 +++++++++++ {PluralKit => PluralKit.Core}/Schema.cs | 0 {PluralKit => PluralKit.Core}/Stores.cs | 0 {PluralKit => PluralKit.Core}/TaskUtils.cs | 0 {PluralKit => PluralKit.Core}/Utils.cs | 10 +-------- PluralKit.csproj | 16 -------------- PluralKit.sln | 22 +++++++++++++++++++ 21 files changed, 54 insertions(+), 25 deletions(-) rename {PluralKit/Bot => PluralKit.Bot}/Bot.cs (100%) rename {PluralKit/Bot => PluralKit.Bot}/Commands/MemberCommands.cs (100%) rename {PluralKit/Bot => PluralKit.Bot}/Commands/MiscCommands.cs (100%) rename {PluralKit/Bot => PluralKit.Bot}/Commands/SystemCommands.cs (100%) rename {PluralKit/Bot => PluralKit.Bot}/ContextUtils.cs (100%) rename {PluralKit/Bot => PluralKit.Bot}/Errors.cs (100%) rename {PluralKit/Bot => PluralKit.Bot}/Limits.cs (100%) create mode 100644 PluralKit.Bot/PluralKit.Bot.csproj rename {PluralKit/Bot => PluralKit.Bot}/Preconditions.cs (100%) rename {PluralKit/Bot => PluralKit.Bot}/Services/EmbedService.cs (100%) rename {PluralKit/Bot => PluralKit.Bot}/Services/LogChannelService.cs (100%) rename {PluralKit/Bot => PluralKit.Bot}/Services/ProxyService.cs (100%) rename {PluralKit/Bot => PluralKit.Bot}/Utils.cs (100%) rename {PluralKit => PluralKit.Core}/Models.cs (100%) create mode 100644 PluralKit.Core/PluralKit.Core.csproj rename {PluralKit => PluralKit.Core}/Schema.cs (100%) rename {PluralKit => PluralKit.Core}/Stores.cs (100%) rename {PluralKit => PluralKit.Core}/TaskUtils.cs (100%) rename {PluralKit => PluralKit.Core}/Utils.cs (79%) delete mode 100644 PluralKit.csproj create mode 100644 PluralKit.sln diff --git a/PluralKit/Bot/Bot.cs b/PluralKit.Bot/Bot.cs similarity index 100% rename from PluralKit/Bot/Bot.cs rename to PluralKit.Bot/Bot.cs diff --git a/PluralKit/Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs similarity index 100% rename from PluralKit/Bot/Commands/MemberCommands.cs rename to PluralKit.Bot/Commands/MemberCommands.cs diff --git a/PluralKit/Bot/Commands/MiscCommands.cs b/PluralKit.Bot/Commands/MiscCommands.cs similarity index 100% rename from PluralKit/Bot/Commands/MiscCommands.cs rename to PluralKit.Bot/Commands/MiscCommands.cs diff --git a/PluralKit/Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs similarity index 100% rename from PluralKit/Bot/Commands/SystemCommands.cs rename to PluralKit.Bot/Commands/SystemCommands.cs diff --git a/PluralKit/Bot/ContextUtils.cs b/PluralKit.Bot/ContextUtils.cs similarity index 100% rename from PluralKit/Bot/ContextUtils.cs rename to PluralKit.Bot/ContextUtils.cs diff --git a/PluralKit/Bot/Errors.cs b/PluralKit.Bot/Errors.cs similarity index 100% rename from PluralKit/Bot/Errors.cs rename to PluralKit.Bot/Errors.cs diff --git a/PluralKit/Bot/Limits.cs b/PluralKit.Bot/Limits.cs similarity index 100% rename from PluralKit/Bot/Limits.cs rename to PluralKit.Bot/Limits.cs diff --git a/PluralKit.Bot/PluralKit.Bot.csproj b/PluralKit.Bot/PluralKit.Bot.csproj new file mode 100644 index 00000000..4c7c476f --- /dev/null +++ b/PluralKit.Bot/PluralKit.Bot.csproj @@ -0,0 +1,18 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>netcoreapp2.2</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\PluralKit.Core\PluralKit.Core.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Discord.Net.Commands" Version="2.0.1" /> + <PackageReference Include="Discord.Net.Webhook" Version="2.0.1" /> + <PackageReference Include="Discord.Net.WebSocket" Version="2.0.1" /> + </ItemGroup> + +</Project> diff --git a/PluralKit/Bot/Preconditions.cs b/PluralKit.Bot/Preconditions.cs similarity index 100% rename from PluralKit/Bot/Preconditions.cs rename to PluralKit.Bot/Preconditions.cs diff --git a/PluralKit/Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs similarity index 100% rename from PluralKit/Bot/Services/EmbedService.cs rename to PluralKit.Bot/Services/EmbedService.cs diff --git a/PluralKit/Bot/Services/LogChannelService.cs b/PluralKit.Bot/Services/LogChannelService.cs similarity index 100% rename from PluralKit/Bot/Services/LogChannelService.cs rename to PluralKit.Bot/Services/LogChannelService.cs diff --git a/PluralKit/Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs similarity index 100% rename from PluralKit/Bot/Services/ProxyService.cs rename to PluralKit.Bot/Services/ProxyService.cs diff --git a/PluralKit/Bot/Utils.cs b/PluralKit.Bot/Utils.cs similarity index 100% rename from PluralKit/Bot/Utils.cs rename to PluralKit.Bot/Utils.cs diff --git a/PluralKit/Models.cs b/PluralKit.Core/Models.cs similarity index 100% rename from PluralKit/Models.cs rename to PluralKit.Core/Models.cs diff --git a/PluralKit.Core/PluralKit.Core.csproj b/PluralKit.Core/PluralKit.Core.csproj new file mode 100644 index 00000000..54d15f28 --- /dev/null +++ b/PluralKit.Core/PluralKit.Core.csproj @@ -0,0 +1,13 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>netcoreapp2.2</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Dapper" Version="1.60.6" /> + <PackageReference Include="Dapper.Contrib" Version="1.60.1" /> + <PackageReference Include="Npgsql" Version="4.0.6" /> + </ItemGroup> + +</Project> diff --git a/PluralKit/Schema.cs b/PluralKit.Core/Schema.cs similarity index 100% rename from PluralKit/Schema.cs rename to PluralKit.Core/Schema.cs diff --git a/PluralKit/Stores.cs b/PluralKit.Core/Stores.cs similarity index 100% rename from PluralKit/Stores.cs rename to PluralKit.Core/Stores.cs diff --git a/PluralKit/TaskUtils.cs b/PluralKit.Core/TaskUtils.cs similarity index 100% rename from PluralKit/TaskUtils.cs rename to PluralKit.Core/TaskUtils.cs diff --git a/PluralKit/Utils.cs b/PluralKit.Core/Utils.cs similarity index 79% rename from PluralKit/Utils.cs rename to PluralKit.Core/Utils.cs index 75b5864f..efffffe1 100644 --- a/PluralKit/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -1,13 +1,5 @@ using System; -using System.Data; -using System.Threading; -using System.Threading.Tasks; -using Dapper; -using Discord; -using Discord.Commands; -using Discord.Commands.Builders; -using Discord.WebSocket; -using Microsoft.Extensions.DependencyInjection; + namespace PluralKit { diff --git a/PluralKit.csproj b/PluralKit.csproj deleted file mode 100644 index 2d568ed3..00000000 --- a/PluralKit.csproj +++ /dev/null @@ -1,16 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <PropertyGroup> - <OutputType>Exe</OutputType> - <TargetFramework>netcoreapp2.2</TargetFramework> - </PropertyGroup> - - <ItemGroup> - <PackageReference Include="Dapper" Version="1.60.1" /> - <PackageReference Include="Dapper.Contrib" Version="1.60.1" /> - <PackageReference Include="Discord.Net" Version="2.0.1" /> - <PackageReference Include="Npgsql" Version="4.0.4" /> - <PackageReference Include="Npgsql.Json.NET" Version="4.0.4" /> - </ItemGroup> - -</Project> diff --git a/PluralKit.sln b/PluralKit.sln new file mode 100644 index 00000000..afa744d5 --- /dev/null +++ b/PluralKit.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.Bot", "PluralKit.Bot\PluralKit.Bot.csproj", "{F2C5562D-FD96-4C11-B54E-93737D127959}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.Core", "PluralKit.Core\PluralKit.Core.csproj", "{5DBE037D-179D-4C05-8A28-35E37129C961}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F2C5562D-FD96-4C11-B54E-93737D127959}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2C5562D-FD96-4C11-B54E-93737D127959}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2C5562D-FD96-4C11-B54E-93737D127959}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2C5562D-FD96-4C11-B54E-93737D127959}.Release|Any CPU.Build.0 = Release|Any CPU + {5DBE037D-179D-4C05-8A28-35E37129C961}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DBE037D-179D-4C05-8A28-35E37129C961}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DBE037D-179D-4C05-8A28-35E37129C961}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DBE037D-179D-4C05-8A28-35E37129C961}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal From 9b49f220489a085649a20baaab07520ce5bdd13a Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 8 May 2019 20:08:56 +0200 Subject: [PATCH 022/103] bot: enable .NET configuration management --- PluralKit.Bot/Bot.cs | 20 ++++++++++++-------- PluralKit.Bot/BotConfig.cs | 7 +++++++ PluralKit.Core/CoreConfig.cs | 7 +++++++ PluralKit.Core/PluralKit.Core.csproj | 4 ++++ PluralKit.Core/Stores.cs | 6 +++--- 5 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 PluralKit.Bot/BotConfig.cs create mode 100644 PluralKit.Core/CoreConfig.cs diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 41591916..13f4d545 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -8,19 +8,20 @@ using Dapper; using Discord; using Discord.Commands; using Discord.WebSocket; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Npgsql; -using Npgsql.BackendMessages; -using Npgsql.PostgresTypes; -using Npgsql.TypeHandling; -using Npgsql.TypeMapping; -using NpgsqlTypes; namespace PluralKit.Bot { class Initialize { - static void Main() => new Initialize().MainAsync().GetAwaiter().GetResult(); + private IConfiguration _config; + + static void Main(string[] args) => new Initialize { _config = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddCommandLine(args) + .Build()}.MainAsync().GetAwaiter().GetResult(); private async Task MainAsync() { @@ -37,13 +38,13 @@ namespace PluralKit.Bot { Console.WriteLine("- Connecting to database..."); var connection = services.GetRequiredService<IDbConnection>() as NpgsqlConnection; - connection.ConnectionString = Environment.GetEnvironmentVariable("PK_DATABASE_URI"); + connection.ConnectionString = services.GetRequiredService<CoreConfig>().Database; await connection.OpenAsync(); await Schema.CreateTables(connection); Console.WriteLine("- Connecting to Discord..."); var client = services.GetRequiredService<IDiscordClient>() as DiscordSocketClient; - await client.LoginAsync(TokenType.Bot, Environment.GetEnvironmentVariable("PK_TOKEN")); + await client.LoginAsync(TokenType.Bot, services.GetRequiredService<BotConfig>().Token); await client.StartAsync(); Console.WriteLine("- Initializing bot..."); @@ -54,6 +55,9 @@ namespace PluralKit.Bot } public ServiceProvider BuildServiceProvider() => new ServiceCollection() + .AddSingleton(_config.GetSection("PluralKit").Get<CoreConfig>() ?? new CoreConfig()) + .AddSingleton(_config.GetSection("PluralKit").GetSection("Bot").Get<BotConfig>() ?? new BotConfig()) + .AddSingleton<IDiscordClient, DiscordSocketClient>() .AddSingleton<IDbConnection, NpgsqlConnection>() .AddSingleton<Bot>() diff --git a/PluralKit.Bot/BotConfig.cs b/PluralKit.Bot/BotConfig.cs new file mode 100644 index 00000000..87c2035a --- /dev/null +++ b/PluralKit.Bot/BotConfig.cs @@ -0,0 +1,7 @@ +namespace PluralKit.Bot +{ + public class BotConfig + { + public string Token { get; set; } + } +} \ No newline at end of file diff --git a/PluralKit.Core/CoreConfig.cs b/PluralKit.Core/CoreConfig.cs new file mode 100644 index 00000000..38039b67 --- /dev/null +++ b/PluralKit.Core/CoreConfig.cs @@ -0,0 +1,7 @@ +namespace PluralKit +{ + public class CoreConfig + { + public string Database { get; set; } + } +} \ No newline at end of file diff --git a/PluralKit.Core/PluralKit.Core.csproj b/PluralKit.Core/PluralKit.Core.csproj index 54d15f28..12484212 100644 --- a/PluralKit.Core/PluralKit.Core.csproj +++ b/PluralKit.Core/PluralKit.Core.csproj @@ -7,6 +7,10 @@ <ItemGroup> <PackageReference Include="Dapper" Version="1.60.6" /> <PackageReference Include="Dapper.Contrib" Version="1.60.1" /> + <PackageReference Include="Microsoft.Extensions.Configuration" Version="2.2.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="2.2.4" /> + <PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="2.2.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="2.2.4" /> <PackageReference Include="Npgsql" Version="4.0.6" /> </ItemGroup> diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index e81032ce..3dba40f6 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -25,15 +25,15 @@ namespace PluralKit { } 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 }); + return await conn.QuerySingleOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.system = system.id and accounts.uid = @Id", new { Id = accountId }); } public async Task<PKSystem> GetByHid(string hid) { - return await conn.QuerySingleAsync<PKSystem>("select * from systems where systems.hid = @Hid", new { Hid = hid.ToLower() }); + return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where systems.hid = @Hid", new { Hid = hid.ToLower() }); } public async Task<PKSystem> GetByToken(string token) { - return await conn.QuerySingleAsync<PKSystem>("select * from systems where token = @Token", new { Token = token }); + return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where token = @Token", new { Token = token }); } public async Task Save(PKSystem system) { From 495edc3c5eebdbbabf607ba6ceadd6fe4cc227aa Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 8 May 2019 20:53:01 +0200 Subject: [PATCH 023/103] bot: use MemberStore in member type reader --- PluralKit.Bot/Utils.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index 79823af1..e1229886 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -75,19 +75,19 @@ namespace PluralKit.Bot { public override async Task<TypeReaderResult> ReadAsync(ICommandContext context, string input, IServiceProvider services) { - var conn = services.GetService(typeof(IDbConnection)) as IDbConnection; + var members = services.GetRequiredService<MemberStore>(); // 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 }); + var foundByName = await members.GetByName(ctx.SenderSystem, 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 }); + var foundByHid = await members.GetByHid(input); if (foundByHid != null) return TypeReaderResult.FromSuccess(foundByHid); return TypeReaderResult.FromError(CommandError.ObjectNotFound, "Member not found."); } From 95a7e5e821c5441278019e6781a89dbb603f362f Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 8 May 2019 20:53:36 +0200 Subject: [PATCH 024/103] web: add basic ASP.NET Core web interface --- PluralKit.Web/Pages/Error.cshtml | 26 +++++ PluralKit.Web/Pages/Error.cshtml.cs | 23 ++++ PluralKit.Web/Pages/Index.cshtml | 10 ++ PluralKit.Web/Pages/Index.cshtml.cs | 16 +++ PluralKit.Web/Pages/Shared/_Layout.cshtml | 38 +++++++ PluralKit.Web/Pages/ViewSystem.cshtml | 122 ++++++++++++++++++++++ PluralKit.Web/Pages/ViewSystem.cshtml.cs | 32 ++++++ PluralKit.Web/Pages/_ViewImports.cshtml | 3 + PluralKit.Web/Pages/_ViewStart.cshtml | 3 + PluralKit.Web/PluralKit.Web.csproj | 21 ++++ PluralKit.Web/Program.cs | 19 ++++ PluralKit.Web/Startup.cs | 55 ++++++++++ PluralKit.sln | 6 ++ 13 files changed, 374 insertions(+) create mode 100644 PluralKit.Web/Pages/Error.cshtml create mode 100644 PluralKit.Web/Pages/Error.cshtml.cs create mode 100644 PluralKit.Web/Pages/Index.cshtml create mode 100644 PluralKit.Web/Pages/Index.cshtml.cs create mode 100644 PluralKit.Web/Pages/Shared/_Layout.cshtml create mode 100644 PluralKit.Web/Pages/ViewSystem.cshtml create mode 100644 PluralKit.Web/Pages/ViewSystem.cshtml.cs create mode 100644 PluralKit.Web/Pages/_ViewImports.cshtml create mode 100644 PluralKit.Web/Pages/_ViewStart.cshtml create mode 100644 PluralKit.Web/PluralKit.Web.csproj create mode 100644 PluralKit.Web/Program.cs create mode 100644 PluralKit.Web/Startup.cs diff --git a/PluralKit.Web/Pages/Error.cshtml b/PluralKit.Web/Pages/Error.cshtml new file mode 100644 index 00000000..6f92b956 --- /dev/null +++ b/PluralKit.Web/Pages/Error.cshtml @@ -0,0 +1,26 @@ +@page +@model ErrorModel +@{ + ViewData["Title"] = "Error"; +} + +<h1 class="text-danger">Error.</h1> +<h2 class="text-danger">An error occurred while processing your request.</h2> + +@if (Model.ShowRequestId) +{ + <p> + <strong>Request ID:</strong> <code>@Model.RequestId</code> + </p> +} + +<h3>Development Mode</h3> +<p> + Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred. +</p> +<p> + <strong>The Development environment shouldn't be enabled for deployed applications.</strong> + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong> + and restarting the app. +</p> diff --git a/PluralKit.Web/Pages/Error.cshtml.cs b/PluralKit.Web/Pages/Error.cshtml.cs new file mode 100644 index 00000000..efaccc32 --- /dev/null +++ b/PluralKit.Web/Pages/Error.cshtml.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace PluralKit.Web.Pages +{ + [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + public class ErrorModel : PageModel + { + public string RequestId { get; set; } + + public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + public void OnGet() + { + RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; + } + } +} \ No newline at end of file diff --git a/PluralKit.Web/Pages/Index.cshtml b/PluralKit.Web/Pages/Index.cshtml new file mode 100644 index 00000000..b5f0c15f --- /dev/null +++ b/PluralKit.Web/Pages/Index.cshtml @@ -0,0 +1,10 @@ +@page +@model IndexModel +@{ + ViewData["Title"] = "Home page"; +} + +<div class="text-center"> + <h1 class="display-4">Welcome</h1> + <p>Learn about <a href="https://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p> +</div> diff --git a/PluralKit.Web/Pages/Index.cshtml.cs b/PluralKit.Web/Pages/Index.cshtml.cs new file mode 100644 index 00000000..00158342 --- /dev/null +++ b/PluralKit.Web/Pages/Index.cshtml.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace PluralKit.Web.Pages +{ + public class IndexModel : PageModel + { + public void OnGet() + { + } + } +} \ No newline at end of file diff --git a/PluralKit.Web/Pages/Shared/_Layout.cshtml b/PluralKit.Web/Pages/Shared/_Layout.cshtml new file mode 100644 index 00000000..5f0d0a73 --- /dev/null +++ b/PluralKit.Web/Pages/Shared/_Layout.cshtml @@ -0,0 +1,38 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>@ViewData["Title"] - PluralKit.Web</title> + + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/css/bootstrap.min.css"/> + <link href="https://fonts.googleapis.com/css?family=PT+Sans" rel="stylesheet"> + + <style> + body { + font-family: "PT Sans", sans-serif; + } + </style> +</head> +<body> + +<div class="container"> + <main role="main" class="p-3"> + @RenderBody() + </main> +</div> + +<footer class="border-top footer text-muted"> + <div class="container"> + </div> +</footer> + +@RenderSection("Scripts", required: false) + +<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/js/bootstrap.min.js"></script> +<script src="https://cdnjs.cloudflare.com/ajax/libs/feather-icons/4.21.0/feather.min.js"></script> +<script> + feather.replace(); +</script> +</body> +</html> \ No newline at end of file diff --git a/PluralKit.Web/Pages/ViewSystem.cshtml b/PluralKit.Web/Pages/ViewSystem.cshtml new file mode 100644 index 00000000..7b96e6ce --- /dev/null +++ b/PluralKit.Web/Pages/ViewSystem.cshtml @@ -0,0 +1,122 @@ +@page "/s/{systemId}" +@model PluralKit.Web.Pages.ViewSystem + +<div class="system"> + <ul class="taglist"> + <li> + <i data-feather="hash"></i> + @Model.System.Hid + </li> + + @if (Model.System.Tag != null) + { + <li> + <i data-feather="tag"></i> + @Model.System.Tag + </li> + } + + @if (Model.System.UiTz != null) + { + <li> + <i data-feather="clock"></i> + @Model.System.UiTz + </li> + } + </ul> + + @if (Model.System.Name != null) + { + <h1>@Model.System.Name</h1> + } + + @if (Model.System.Description != null) + { + <p>@Model.System.Description</p> + } + + <h2>Members</h2> + @foreach (var member in Model.Members) + { + <div class="member-card"> + <div class="member-avatar" style="background-image: url(@(member.AvatarUrl)"></div> + <div class="member-body"> + <span class="member-name">@member.Name</span> + <p class="member-description">@member.Description</p> + + <ul class="taglist"> + <li> + <i data-feather="hash"></i> + @member.Hid + </li> + + @if (member.Birthday != null) + { + <li> + <i data-feather="calendar"></i> + @member.BirthdayString + </li> + } + + @if (member.Pronouns != null) + { + <li> + <i data-feather="message-circle"></i> + @member.Pronouns + </li> + } + </ul> + </div> + </div> + } +</div> + +@section Scripts { + <style> + .taglist { + margin: 0; + padding: 0; + color: #aaa; + display: flex; + } + + .taglist li { + display: inline-block; + margin-right: 1rem; + list-style-type: none; + } + + .taglist .feather { + display: inline-block; + margin-top: -2px; + width: 1em; + } + + .member-card { + display: flex; + flex-direction: row; + } + + .member-avatar { + margin: 1.5rem 1rem 0 0; + border-radius: 50%; + background-size: cover; + background-position: top center; + flex-basis: 4rem; + height: 4rem; + border: 4px solid white; + } + + .member-body { + flex: 1; + display: flex; + flex-direction: column; + padding: 1rem 1rem 1rem 0; + } + + .member-name { + font-size: 13pt; + font-weight: bold; + } + </style> +} \ No newline at end of file diff --git a/PluralKit.Web/Pages/ViewSystem.cshtml.cs b/PluralKit.Web/Pages/ViewSystem.cshtml.cs new file mode 100644 index 00000000..b231e7f4 --- /dev/null +++ b/PluralKit.Web/Pages/ViewSystem.cshtml.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace PluralKit.Web.Pages +{ + public class ViewSystem : PageModel + { + private SystemStore _systems; + private MemberStore _members; + + public ViewSystem(SystemStore systems, MemberStore members) + { + _systems = systems; + _members = members; + } + + public PKSystem System { get; set; } + public IEnumerable<PKMember> Members { get; set; } + + public async Task<IActionResult> OnGet(string systemId) + { + System = await _systems.GetByHid(systemId); + if (System == null) return NotFound(); + + Members = await _members.GetBySystem(System); + + return Page(); + } + } +} \ No newline at end of file diff --git a/PluralKit.Web/Pages/_ViewImports.cshtml b/PluralKit.Web/Pages/_ViewImports.cshtml new file mode 100644 index 00000000..82dd3ee0 --- /dev/null +++ b/PluralKit.Web/Pages/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@using PluralKit.Web +@namespace PluralKit.Web.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/PluralKit.Web/Pages/_ViewStart.cshtml b/PluralKit.Web/Pages/_ViewStart.cshtml new file mode 100644 index 00000000..24f33e00 --- /dev/null +++ b/PluralKit.Web/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "Shared/_Layout"; +} \ No newline at end of file diff --git a/PluralKit.Web/PluralKit.Web.csproj b/PluralKit.Web/PluralKit.Web.csproj new file mode 100644 index 00000000..74760155 --- /dev/null +++ b/PluralKit.Web/PluralKit.Web.csproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>netcoreapp2.2</TargetFramework> + <DebugType>full</DebugType> + </PropertyGroup> + + + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" /> + <PackageReference Include="Microsoft.AspNetCore.HttpsPolicy" Version="2.2.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> + <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" /> + </ItemGroup> + + + <ItemGroup> + <ProjectReference Include="..\PluralKit.Core\PluralKit.Core.csproj" /> + </ItemGroup> + +</Project> diff --git a/PluralKit.Web/Program.cs b/PluralKit.Web/Program.cs new file mode 100644 index 00000000..82af97b6 --- /dev/null +++ b/PluralKit.Web/Program.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; + +namespace PluralKit.Web +{ + public class Program + { + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) + { + return WebHost.CreateDefaultBuilder(args) + .UseStartup<Startup>(); + } + } +} \ No newline at end of file diff --git a/PluralKit.Web/Startup.cs b/PluralKit.Web/Startup.cs new file mode 100644 index 00000000..3b201fd6 --- /dev/null +++ b/PluralKit.Web/Startup.cs @@ -0,0 +1,55 @@ +using System.Data; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace PluralKit.Web +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + var config = Configuration.GetSection("PluralKit").Get<CoreConfig>(); + + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); + + services + .AddSingleton<IDbConnection, NpgsqlConnection>(_ => new NpgsqlConnection(config.Database)) + .AddSingleton<SystemStore>() + .AddSingleton<MemberStore>() + .AddSingleton(config); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public async void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + else + { + app.UseExceptionHandler("/Error"); + } + + //app.UseHttpsRedirection(); + app.UseMvc(); + + var conn = app.ApplicationServices.GetRequiredService<IDbConnection>(); + conn.Open(); + + await Schema.CreateTables(conn); + } + } +} \ No newline at end of file diff --git a/PluralKit.sln b/PluralKit.sln index afa744d5..18256e5c 100644 --- a/PluralKit.sln +++ b/PluralKit.sln @@ -4,6 +4,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.Bot", "PluralKit. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.Core", "PluralKit.Core\PluralKit.Core.csproj", "{5DBE037D-179D-4C05-8A28-35E37129C961}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.Web", "PluralKit.Web\PluralKit.Web.csproj", "{975F9DED-78D1-4742-8412-DF70BB381E92}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -18,5 +20,9 @@ Global {5DBE037D-179D-4C05-8A28-35E37129C961}.Debug|Any CPU.Build.0 = Debug|Any CPU {5DBE037D-179D-4C05-8A28-35E37129C961}.Release|Any CPU.ActiveCfg = Release|Any CPU {5DBE037D-179D-4C05-8A28-35E37129C961}.Release|Any CPU.Build.0 = Release|Any CPU + {975F9DED-78D1-4742-8412-DF70BB381E92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {975F9DED-78D1-4742-8412-DF70BB381E92}.Debug|Any CPU.Build.0 = Debug|Any CPU + {975F9DED-78D1-4742-8412-DF70BB381E92}.Release|Any CPU.ActiveCfg = Release|Any CPU + {975F9DED-78D1-4742-8412-DF70BB381E92}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal From 6cd36e2c5e049441bb44eb04d637e408a9fb173a Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 8 May 2019 21:16:41 +0200 Subject: [PATCH 025/103] web: add Docker support for web --- Dockerfile | 6 ++++-- docker-compose.yml | 12 ++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1e5014fd..5f7fe289 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ FROM mcr.microsoft.com/dotnet/core/sdk:2.2-alpine WORKDIR /app -COPY PluralKit/ PluralKit.csproj /app/ +COPY PluralKit.Bot /app/PluralKit.Bot +COPY PluralKit.Core /app/PluralKit.Core +COPY PluralKit.Web /app/PluralKit.Web +COPY PluralKit.sln /app RUN dotnet build -ENTRYPOINT ["dotnet", "run"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index be4ca6e3..cea99fef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,10 +2,18 @@ version: "3" services: bot: build: . + entrypoint: ["dotnet", "run", "--project", "PluralKit.Bot"] environment: - - PK_TOKEN - - "PK_DATABASE_URI=Host=db;Username=postgres;Password=postgres;Database=postgres" + - "PluralKit:Bot:Token" + - "PluralKit:Database=Host=db;Username=postgres;Password=postgres;Database=postgres" links: - db + web: + build: . + entrypoint: ["dotnet", "run", "--project", "PluralKit.Web"] + environment: + - "PluralKit:Database=Host=db;Username=postgres;Password=postgres;Database=postgres" + links: + - db db: image: postgres:alpine \ No newline at end of file From 12e14c420c07a49ca4e948d9bdbbc225e1764419 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 8 May 2019 21:39:30 +0200 Subject: [PATCH 026/103] web: fix a few small issues --- PluralKit.Web/Pages/Shared/_Layout.cshtml | 3 ++- PluralKit.Web/Pages/ViewSystem.cshtml | 6 +++--- docker-compose.yml | 2 ++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/PluralKit.Web/Pages/Shared/_Layout.cshtml b/PluralKit.Web/Pages/Shared/_Layout.cshtml index 5f0d0a73..556e8ada 100644 --- a/PluralKit.Web/Pages/Shared/_Layout.cshtml +++ b/PluralKit.Web/Pages/Shared/_Layout.cshtml @@ -29,8 +29,9 @@ @RenderSection("Scripts", required: false) -<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/js/bootstrap.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/feather-icons/4.21.0/feather.min.js"></script> +<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script> +<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/js/bootstrap.min.js"></script> <script> feather.replace(); </script> diff --git a/PluralKit.Web/Pages/ViewSystem.cshtml b/PluralKit.Web/Pages/ViewSystem.cshtml index 7b96e6ce..8a44b5f3 100644 --- a/PluralKit.Web/Pages/ViewSystem.cshtml +++ b/PluralKit.Web/Pages/ViewSystem.cshtml @@ -32,17 +32,17 @@ @if (Model.System.Description != null) { - <p>@Model.System.Description</p> + <div>@Model.System.Description</div> } <h2>Members</h2> @foreach (var member in Model.Members) { <div class="member-card"> - <div class="member-avatar" style="background-image: url(@(member.AvatarUrl)"></div> + <div class="member-avatar" style="background-image: url(@member.AvatarUrl); border-color: #@member.Color;"></div> <div class="member-body"> <span class="member-name">@member.Name</span> - <p class="member-description">@member.Description</p> + <div class="member-description">@member.Description</div> <ul class="taglist"> <li> diff --git a/docker-compose.yml b/docker-compose.yml index cea99fef..ed2ca8d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,5 +15,7 @@ services: - "PluralKit:Database=Host=db;Username=postgres;Password=postgres;Database=postgres" links: - db + ports: + - 2837:80 db: image: postgres:alpine \ No newline at end of file From cf2598baa5ae89cd5564d9e6580ce60290aeb174 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sat, 11 May 2019 23:56:56 +0200 Subject: [PATCH 027/103] bot: add color change command --- PluralKit.Bot/Commands/MemberCommands.cs | 29 ++++++++++++++++++++---- PluralKit.Bot/Commands/SystemCommands.cs | 2 +- PluralKit.Bot/Errors.cs | 4 +++- PluralKit.Core/Utils.cs | 6 +++++ 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index 3339bd78..322f43b1 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -1,4 +1,5 @@ using System; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Discord.Commands; @@ -63,7 +64,7 @@ namespace PluralKit.Bot.Commands if (!await Context.PromptYesNo(msg)) throw new PKError("Member renaming cancelled."); } - // Rename the mebmer + // Rename the member ContextEntity.Name = newName; await Members.Save(ContextEntity); @@ -73,10 +74,10 @@ namespace PluralKit.Bot.Commands [Command("description")] [Alias("info", "bio", "text")] - [Remarks("member <member> description <description")] + [Remarks("member <member> description <description>")] [MustPassOwnMember] public async Task MemberDescription([Remainder] string description = null) { - if (description.Length > Limits.MaxDescriptionLength) throw Errors.DescriptionTooLongError(description.Length); + if (description.IsLongerThan(Limits.MaxDescriptionLength)) throw Errors.DescriptionTooLongError(description.Length); ContextEntity.Description = description; await Members.Save(ContextEntity); @@ -86,10 +87,10 @@ namespace PluralKit.Bot.Commands [Command("pronouns")] [Alias("pronoun")] - [Remarks("member <member> pronouns <pronouns")] + [Remarks("member <member> pronouns <pronouns>")] [MustPassOwnMember] public async Task MemberPronouns([Remainder] string pronouns = null) { - if (pronouns.Length > Limits.MaxPronounsLength) throw Errors.MemberPronounsTooLongError(pronouns.Length); + if (pronouns.IsLongerThan(Limits.MaxPronounsLength)) throw Errors.MemberPronounsTooLongError(pronouns.Length); ContextEntity.Pronouns = pronouns; await Members.Save(ContextEntity); @@ -97,6 +98,24 @@ namespace PluralKit.Bot.Commands await Context.Channel.SendMessageAsync($"{Emojis.Success} Member pronouns {(pronouns == null ? "cleared" : "changed")}."); } + [Command("color")] + [Alias("colour")] + [Remarks("member <member> color <color>")] + [MustPassOwnMember] + public async Task MemberColor([Remainder] string color = null) + { + if (color != null) + { + if (color.StartsWith("#")) color = color.Substring(1); + if (!Regex.IsMatch(color, "[0-9a-f]{6}")) throw Errors.InvalidColorError(color); + } + + ContextEntity.Color = color; + await Members.Save(ContextEntity); + + await Context.Channel.SendMessageAsync($"{Emojis.Success} Member color {(color == null ? "cleared" : "changed")}."); + } + public override async Task<PKMember> ReadContextParameterAsync(string value) { var res = await new PKMemberTypeReader().ReadAsync(Context, value, _services); diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index 851c5968..82b16e11 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -30,7 +30,7 @@ namespace PluralKit.Bot.Commands public async Task New([Remainder] string systemName = null) { if (ContextEntity != null) throw Errors.NotOwnSystemError; - if (Context.SenderSystem != null) throw Errors.NoSystemError; + if (Context.SenderSystem != null) throw Errors.ExistingSystemError; var system = await Systems.Create(systemName); await Systems.Link(system, Context.User.Id); diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 7508fce9..f4caee3c 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -5,7 +5,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 NoSystemError => new PKError("You do not have a system registered with PluralKit. To create one, type `pk;system new`."); - public static PKError ExistinSystemError => 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 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."); public static PKError SystemNameTooLongError(int length) => new PKError($"System name too long ({length}/{Limits.MaxSystemNameLength} characters)."); @@ -13,5 +13,7 @@ namespace PluralKit.Bot { public static PKError DescriptionTooLongError(int length) => new PKError($"Description too long ({length}/{Limits.MaxDescriptionLength} characters)."); public static PKError MemberNameTooLongError(int length) => new PKError($"Member name too long ({length}/{Limits.MaxMemberNameLength} characters)."); public static PKError MemberPronounsTooLongError(int length) => new PKError($"Member pronouns too long ({length}/{Limits.MaxMemberNameLength} characters)."); + + public static PKError InvalidColorError(string color) => new PKError($"{color} is not a valid color. Color must be in hex format (eg. #ff0000)."); } } \ No newline at end of file diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index efffffe1..a4afe474 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -21,6 +21,12 @@ namespace PluralKit if (str.Length < maxLength) return str; return str.Substring(0, maxLength - ellipsis.Length) + ellipsis; } + + public static bool IsLongerThan(this string str, int length) + { + if (str != null) return str.Length > length; + return false; + } } public static class Emojis { From c63e20ca50dfebd7bc6c453156a095b8aa6b3a37 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sun, 12 May 2019 00:22:48 +0200 Subject: [PATCH 028/103] bot: fix command dispatch --- PluralKit.Bot/Utils.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index e1229886..161fa8c1 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -153,8 +153,12 @@ namespace PluralKit.Bot public override async Task<PreconditionResult> CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) { + // This stops the "delegating command" we define above from being called multiple times + // If we've already added a context object to the context, then we'll return with the same + // error you get when there's an invalid command - it's like it didn't exist + // This makes sure the user gets the proper error, instead of the command trying to parse things weirdly if ((context as PKCommandContext)?.GetContextEntity<object>() == null) return PreconditionResult.FromSuccess(); - return PreconditionResult.FromError("(should not be seen)"); + return PreconditionResult.FromError(command.Module.Service.Search("<unknown>")); } } From b42e052fee6fc514103ff361ea17effcedfbde98 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sun, 12 May 2019 00:44:02 +0200 Subject: [PATCH 029/103] bot: add member lookup command --- PluralKit.Bot/Commands/MemberCommands.cs | 10 ++++++++ PluralKit.Bot/Services/EmbedService.cs | 30 +++++++++++++++++++++++- PluralKit.Core/Stores.cs | 10 ++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index 322f43b1..51e91060 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -8,7 +8,9 @@ namespace PluralKit.Bot.Commands [Group("member")] public class MemberCommands : ContextParameterModuleBase<PKMember> { + public SystemStore Systems { get; set; } public MemberStore Members { get; set; } + public EmbedService Embeds { get; set; } public override string Prefix => "member"; public override string ContextNoun => "member"; @@ -115,6 +117,14 @@ namespace PluralKit.Bot.Commands await Context.Channel.SendMessageAsync($"{Emojis.Success} Member color {(color == null ? "cleared" : "changed")}."); } + + [Command] + [Remarks("member")] + public async Task ViewMember(PKMember member) + { + var system = await Systems.GetById(member.Id); + await Context.Channel.SendMessageAsync(embed: await Embeds.CreateMemberEmbed(system, member)); + } public override async Task<PKMember> ReadContextParameterAsync(string value) { diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 9c80a0f4..3c1af3f3 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Linq; using System.Threading.Tasks; using Discord; @@ -5,11 +6,13 @@ using Discord; namespace PluralKit.Bot { public class EmbedService { private SystemStore _systems; + private MemberStore _members; private IDiscordClient _client; - public EmbedService(SystemStore systems, IDiscordClient client) + public EmbedService(SystemStore systems, MemberStore members, IDiscordClient client) { this._systems = systems; + this._members = members; this._client = client; } @@ -41,5 +44,30 @@ namespace PluralKit.Bot { .WithTimestamp(message.Timestamp) .Build(); } + + public async Task<Embed> CreateMemberEmbed(PKSystem system, PKMember member) + { + var name = member.Name; + if (system.Name != null) name = $"{member.Name} ({system.Name})"; + + var color = Color.Default; + if (member.Color != null) color = new Color(uint.Parse(member.Color, NumberStyles.HexNumber)); + + var messageCount = await _members.MessageCount(member); + + var eb = new EmbedBuilder() + // TODO: add URL of website when that's up + .WithAuthor(name, member.AvatarUrl) + .WithColor(color) + .WithDescription(member.Description) + .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid}"); + + if (member.Birthday != null) eb.AddField("Birthdate", member.BirthdayString); + if (member.Pronouns != null) eb.AddField("Pronouns", member.Pronouns); + if (messageCount > 0) eb.AddField("Message Count", messageCount); + if (member.HasProxyTags) eb.AddField("Proxy Tags", $"{member.Prefix}text{member.Suffix}"); + + return eb.Build(); + } } } \ No newline at end of file diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index 3dba40f6..5b27b5b7 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -35,6 +35,11 @@ namespace PluralKit { public async Task<PKSystem> GetByToken(string token) { return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where token = @Token", new { Token = token }); } + + public async Task<PKSystem> GetById(int id) + { + return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where id = @Id", new { Id = id }); + } public async Task Save(PKSystem system) { await conn.ExecuteAsync("update systems set name = @Name, description = @Description, tag = @Tag, avatar_url = @AvatarUrl, token = @Token, ui_tz = @UiTz where id = @Id", system); @@ -95,6 +100,11 @@ namespace PluralKit { public async Task Delete(PKMember member) { await conn.ExecuteAsync("delete from members where id = @Id", member); } + + public async Task<int> MessageCount(PKMember member) + { + return await conn.QuerySingleAsync<int>("select count(*) from messages where member = @Id", member); + } } public class MessageStore { From 62dc2ce78eeb94f1659b2216d0962f505b8106ad Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 13 May 2019 22:44:49 +0200 Subject: [PATCH 030/103] bot: add birthday command --- PluralKit.Bot/Bot.cs | 9 ++++ PluralKit.Bot/Commands/MemberCommands.cs | 23 +++++++- PluralKit.Bot/Errors.cs | 3 +- PluralKit.Bot/Utils.cs | 14 +++++ PluralKit.Core/Models.cs | 14 +++-- PluralKit.Core/PluralKit.Core.csproj | 1 + PluralKit.Core/Utils.cs | 67 ++++++++++++++++++++++++ 7 files changed, 124 insertions(+), 7 deletions(-) diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 13f4d545..bd3810f2 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -10,6 +10,7 @@ using Discord.Commands; using Discord.WebSocket; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using NodaTime; using Npgsql; namespace PluralKit.Bot @@ -34,6 +35,14 @@ namespace PluralKit.Bot SqlMapper.AddTypeHandler<ulong>(new UlongEncodeAsLongHandler()); Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + // Also, use NodaTime. it's good. + NpgsqlConnection.GlobalTypeMapper.UseNodaTime(); + // With the thing we add above, Npgsql already handles NodaTime integration + // This makes Dapper confused since it thinks it has to convert it anyway and doesn't understand the types + // So we add a custom type handler that literally just passes the type through to Npgsql + SqlMapper.AddTypeHandler(new PassthroughTypeHandler<Instant>()); + SqlMapper.AddTypeHandler(new PassthroughTypeHandler<LocalDate>()); + using (var services = BuildServiceProvider()) { Console.WriteLine("- Connecting to database..."); diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index 51e91060..bf511d1f 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -2,6 +2,7 @@ using System; using System.Text.RegularExpressions; using System.Threading.Tasks; using Discord.Commands; +using NodaTime; namespace PluralKit.Bot.Commands { @@ -118,11 +119,31 @@ namespace PluralKit.Bot.Commands await Context.Channel.SendMessageAsync($"{Emojis.Success} Member color {(color == null ? "cleared" : "changed")}."); } + [Command("birthday")] + [Alias("birthdate", "bday", "cakeday", "bdate")] + [Remarks("member <member> birthday <birthday>")] + [MustPassOwnMember] + public async Task MemberBirthday([Remainder] string birthday = null) + { + LocalDate? date = null; + if (birthday != null) + { + date = PluralKit.Utils.ParseDate(birthday, true); + if (date == null) throw Errors.BirthdayParseError(birthday); + } + + ContextEntity.Birthday = date; + await Members.Save(ContextEntity); + + await Context.Channel.SendMessageAsync($"{Emojis.Success} Member birthdate {(date == null ? "cleared" : $"changed to {ContextEntity.BirthdayString}")}."); + } + [Command] + [Alias("view", "show", "info")] [Remarks("member")] public async Task ViewMember(PKMember member) { - var system = await Systems.GetById(member.Id); + var system = await Systems.GetById(member.System); await Context.Channel.SendMessageAsync(embed: await Embeds.CreateMemberEmbed(system, member)); } diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index f4caee3c..45b4e0df 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -14,6 +14,7 @@ namespace PluralKit.Bot { public static PKError MemberNameTooLongError(int length) => new PKError($"Member name too long ({length}/{Limits.MaxMemberNameLength} characters)."); public static PKError MemberPronounsTooLongError(int length) => new PKError($"Member pronouns too long ({length}/{Limits.MaxMemberNameLength} characters)."); - public static PKError InvalidColorError(string color) => new PKError($"{color} is not a valid color. Color must be in hex format (eg. #ff0000)."); + public static PKError InvalidColorError(string color) => new PKError($"\"{color}\" is not a valid color. Color must be in hex format (eg. #ff0000)."); + public static PKError BirthdayParseError(string birthday) => new PKError($"\"{birthday}\" could not be parsed as a valid date. Try a format like \"2016-12-24\" or \"May 3 1996\"."); } } \ No newline at end of file diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index 161fa8c1..a9b9dfa1 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -9,6 +9,7 @@ using Discord.Commands; using Discord.Commands.Builders; using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; +using NodaTime; namespace PluralKit.Bot { @@ -32,6 +33,19 @@ namespace PluralKit.Bot } } + class PassthroughTypeHandler<T> : SqlMapper.TypeHandler<T> + { + public override void SetValue(IDbDataParameter parameter, T value) + { + parameter.Value = value; + } + + public override T Parse(object value) + { + return (T) value; + } + } + class PKSystemTypeReader : TypeReader { public override async Task<TypeReaderResult> ReadAsync(ICommandContext context, string input, IServiceProvider services) diff --git a/PluralKit.Core/Models.cs b/PluralKit.Core/Models.cs index 5afacfa5..98031ad4 100644 --- a/PluralKit.Core/Models.cs +++ b/PluralKit.Core/Models.cs @@ -1,5 +1,7 @@ using System; using Dapper.Contrib.Extensions; +using NodaTime; +using NodaTime.Text; namespace PluralKit { @@ -14,7 +16,7 @@ namespace PluralKit public string Tag { get; set; } public string AvatarUrl { get; set; } public string Token { get; set; } - public DateTime Created { get; set; } + public Instant Created { get; set; } public string UiTz { get; set; } public int MaxMemberNameLength => Tag != null ? 32 - Tag.Length - 1 : 32; @@ -29,12 +31,12 @@ namespace PluralKit public string Color { get; set; } public string AvatarUrl { get; set; } public string Name { get; set; } - public DateTime? Birthday { get; set; } + public LocalDate? Birthday { get; set; } public string Pronouns { get; set; } public string Description { get; set; } public string Prefix { get; set; } public string Suffix { get; set; } - public DateTime Created { get; set; } + public Instant Created { get; set; } /// Returns a formatted string representing the member's birthday, taking into account that a year of "0001" is hidden public string BirthdayString @@ -42,8 +44,10 @@ namespace PluralKit get { if (Birthday == null) return null; - if (Birthday?.Year == 1) return Birthday?.ToString("MMMM dd"); - return Birthday?.ToString("MMMM dd, yyyy"); + + var format = LocalDatePattern.CreateWithInvariantCulture("MMM dd, yyyy"); + if (Birthday?.Year == 1) format = LocalDatePattern.CreateWithInvariantCulture("MMM dd"); + return format.Format(Birthday.Value); } } diff --git a/PluralKit.Core/PluralKit.Core.csproj b/PluralKit.Core/PluralKit.Core.csproj index 12484212..817f3249 100644 --- a/PluralKit.Core/PluralKit.Core.csproj +++ b/PluralKit.Core/PluralKit.Core.csproj @@ -12,6 +12,7 @@ <PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="2.2.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="2.2.4" /> <PackageReference Include="Npgsql" Version="4.0.6" /> + <PackageReference Include="Npgsql.NodaTime" Version="4.0.6" /> </ItemGroup> </Project> diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index a4afe474..943ec7fc 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -1,4 +1,11 @@ using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using NodaTime; +using NodaTime.Text; namespace PluralKit @@ -27,6 +34,66 @@ namespace PluralKit if (str != null) return str.Length > length; return false; } + + public static Duration? ParsePeriod(string str) + { + + Duration d = Duration.Zero; + + foreach (Match match in Regex.Matches(str, "(\\d{1,3})(\\w)")) + { + var amount = int.Parse(match.Groups[1].Value); + var type = match.Groups[2].Value; + + if (type == "w") d += Duration.FromDays(7) * amount; + else if (type == "d") d += Duration.FromDays(1) * amount; + else if (type == "h") d += Duration.FromHours(1) * amount; + else if (type == "m") d += Duration.FromMinutes(1) * amount; + else if (type == "s") d += Duration.FromSeconds(1) * amount; + else return null; + } + + if (d == Duration.Zero) return null; + return d; + } + + public static LocalDate? ParseDate(string str, bool allowNullYear = false) + { + // NodaTime can't parse constructs like "1st" and "2nd" so we quietly replace those away + // Gotta make sure to do the regex otherwise we'll catch things like the "st" in "August" too + str = Regex.Replace(str, "(\\d+)(st|nd|rd|th)", "$1"); + + var patterns = new[] + { + "MMM d yyyy", // Jan 1 2019 + "MMM d, yyyy", // Jan 1, 2019 + "MMMM d yyyy", // January 1 2019 + "MMMM d, yyyy", // January 1, 2019 + "yyyy-MM-dd", // 2019-01-01 + "yyyy MM dd", // 2019 01 01 + "yyyy/MM/dd" // 2019/01/01 + }.ToList(); + + if (allowNullYear) patterns.AddRange(new[] + { + "MMM d", // Jan 1 + "MMMM d", // January 1 + "MM-dd", // 01-01 + "MM dd", // 01 01 + "MM/dd" // 01-01 + }); + + // Giving a template value so year will be parsed as 0001 if not present + // This means we can later disambiguate whether a null year was given + // TODO: should we be using invariant culture here? + foreach (var pattern in patterns.Select(p => LocalDatePattern.CreateWithInvariantCulture(p).WithTemplateValue(new LocalDate(0001, 1, 1)))) + { + var result = pattern.Parse(str); + if (result.Success) return result.Value; + } + + return null; + } } public static class Emojis { From 5fc91d895cb231e4d033542223dce428d9ce26b7 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 13 May 2019 22:56:22 +0200 Subject: [PATCH 031/103] bot: add proxy tag edit command --- PluralKit.Bot/Commands/MemberCommands.cs | 30 +++++++++++++++++++++++- PluralKit.Bot/Errors.cs | 2 ++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index bf511d1f..aead0f82 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -138,6 +138,35 @@ namespace PluralKit.Bot.Commands await Context.Channel.SendMessageAsync($"{Emojis.Success} Member birthdate {(date == null ? "cleared" : $"changed to {ContextEntity.BirthdayString}")}."); } + [Command("proxy")] + [Alias("proxy", "tags", "proxytags", "brackets")] + [Remarks("member <member> proxy <proxy tags>")] + [MustPassOwnMember] + public async Task MemberProxy([Remainder] string exampleProxy = null) + { + // Handling the clear case in an if here to keep the body dedented + if (exampleProxy == null) + { + // Just reset and send OK message + ContextEntity.Prefix = null; + ContextEntity.Suffix = null; + await Members.Save(ContextEntity); + await Context.Channel.SendMessageAsync($"{Emojis.Success} Member proxy tags cleared."); + return; + } + + // Make sure there's one and only one instance of "text" in the example proxy given + var prefixAndSuffix = exampleProxy.Split("text"); + if (prefixAndSuffix.Length < 2) throw Errors.ProxyMustHaveText; + if (prefixAndSuffix.Length > 2) throw Errors.ProxyMultipleText; + + // If the prefix/suffix is empty, use "null" instead (for DB) + ContextEntity.Prefix = prefixAndSuffix[0].Length > 0 ? prefixAndSuffix[0] : null; + ContextEntity.Suffix = prefixAndSuffix[1].Length > 0 ? prefixAndSuffix[1] : null; + await Members.Save(ContextEntity); + await Context.Channel.SendMessageAsync($"{Emojis.Success} Member proxy tags changed to `{ContextEntity.ProxyString}`. Try proxying now!"); + } + [Command] [Alias("view", "show", "info")] [Remarks("member")] @@ -146,7 +175,6 @@ namespace PluralKit.Bot.Commands var system = await Systems.GetById(member.System); await Context.Channel.SendMessageAsync(embed: await Embeds.CreateMemberEmbed(system, member)); } - public override async Task<PKMember> ReadContextParameterAsync(string value) { var res = await new PKMemberTypeReader().ReadAsync(Context, value, _services); diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 45b4e0df..4653fdf3 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -16,5 +16,7 @@ namespace PluralKit.Bot { public static PKError InvalidColorError(string color) => new PKError($"\"{color}\" is not a valid color. Color must be in hex format (eg. #ff0000)."); public static PKError BirthdayParseError(string birthday) => new PKError($"\"{birthday}\" could not be parsed as a valid date. Try a format like \"2016-12-24\" or \"May 3 1996\"."); + public static PKError ProxyMustHaveText => new PKSyntaxError("Example proxy message must contain the string 'text'."); + public static PKError ProxyMultipleText => new PKSyntaxError("Example proxy message must contain the string 'text' exactly once."); } } \ No newline at end of file From 72a2fadff8d7f1a1beec284f7f02bee3eb45fce3 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 13 May 2019 23:08:44 +0200 Subject: [PATCH 032/103] bot: add member deletion command --- PluralKit.Bot/Commands/MemberCommands.cs | 12 ++++++++++++ PluralKit.Bot/ContextUtils.cs | 6 ++++++ PluralKit.Bot/Errors.cs | 2 ++ PluralKit.Bot/Services/ProxyService.cs | 2 +- PluralKit.Core/Utils.cs | 1 + 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index aead0f82..200b6e95 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -167,6 +167,18 @@ namespace PluralKit.Bot.Commands await Context.Channel.SendMessageAsync($"{Emojis.Success} Member proxy tags changed to `{ContextEntity.ProxyString}`. Try proxying now!"); } + [Command("delete")] + [Alias("remove", "erase", "yeet")] + [Remarks("member <member> delete")] + [MustPassOwnMember] + public async Task MemberDelete() + { + await Context.Channel.SendMessageAsync($"{Emojis.Warn} Are you sure you want to delete \"{ContextEntity.Name}\"? If so, reply to this message with the member's ID (`{ContextEntity.Hid}`). __***This cannot be undone!***__"); + if (!await Context.ConfirmWithReply(ContextEntity.Hid)) throw Errors.MemberDeleteCancelled; + await Members.Delete(ContextEntity); + await Context.Channel.SendMessageAsync($"{Emojis.Success} Member deleted."); + } + [Command] [Alias("view", "show", "info")] [Remarks("member")] diff --git a/PluralKit.Bot/ContextUtils.cs b/PluralKit.Bot/ContextUtils.cs index 1b8fa9f5..1c34a886 100644 --- a/PluralKit.Bot/ContextUtils.cs +++ b/PluralKit.Bot/ContextUtils.cs @@ -48,6 +48,12 @@ namespace PluralKit.Bot { (ctx.Client as BaseSocketClient).MessageReceived += Inner; return await (tcs.Task.TimeoutAfter(timeout)); } + + public static async Task<bool> ConfirmWithReply(this ICommandContext ctx, string expectedReply) + { + var msg = await ctx.AwaitMessage(ctx.Channel, ctx.User, timeout: TimeSpan.FromMinutes(1)); + return string.Equals(msg.Content, expectedReply, StringComparison.InvariantCultureIgnoreCase); + } public static async Task Paginate<T>(this ICommandContext ctx, ICollection<T> items, int itemsPerPage, string title, Action<EmbedBuilder, IEnumerable<T>> renderer) { var pageCount = (items.Count / itemsPerPage) + 1; diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 4653fdf3..52ec1539 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -18,5 +18,7 @@ namespace PluralKit.Bot { public static PKError BirthdayParseError(string birthday) => new PKError($"\"{birthday}\" could not be parsed as a valid date. Try a format like \"2016-12-24\" or \"May 3 1996\"."); public static PKError ProxyMustHaveText => new PKSyntaxError("Example proxy message must contain the string 'text'."); public static PKError ProxyMultipleText => new PKSyntaxError("Example proxy message must contain the string 'text' exactly once."); + + public static PKError MemberDeleteCancelled => new PKError($"Member deletion cancelled. Stay safe! {Emojis.ThumbsUp}"); } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index d57ee524..7dee80a6 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -24,7 +24,7 @@ namespace PluralKit.Bot public PKSystem System; public string InnerText; - public string ProxyName => Member.Name + (System.Tag.Length > 0 ? " " + System.Tag : ""); + public string ProxyName => Member.Name + (System.Tag != null ? " " + System.Tag : ""); } class ProxyService { diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index 943ec7fc..61972e54 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -101,5 +101,6 @@ namespace PluralKit public static readonly string Success = "\u2705"; public static readonly string Error = "\u274C"; public static readonly string Note = "\u2757"; + public static readonly string ThumbsUp = "\U0001f44d"; } } \ No newline at end of file From 1824bfd6bbcb1822dcdf5e1e84e68db9a2cd7aa0 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 13 May 2019 23:12:58 +0200 Subject: [PATCH 033/103] bot: fix proxy service testing members with no tags set --- PluralKit.Bot/Services/ProxyService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index 7dee80a6..e43b8f5d 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -48,8 +48,8 @@ namespace PluralKit.Bot private ProxyMatch GetProxyTagMatch(string message, IEnumerable<ProxyDatabaseResult> potentials) { // TODO: add detection of leading @mention - // Sort by specificity (prefix+suffix first, prefix/suffix second) - var ordered = potentials.OrderByDescending((p) => (p.Member.Prefix != null ? 0 : 1) + (p.Member.Suffix != null ? 0 : 1)); + // Sort by specificity (ProxyString length desc = prefix+suffix length desc = inner message asc = more specific proxy first!) + var ordered = potentials.OrderByDescending(p => p.Member.ProxyString.Length); foreach (var potential in ordered) { var prefix = potential.Member.Prefix ?? ""; var suffix = potential.Member.Suffix ?? ""; @@ -63,7 +63,7 @@ namespace PluralKit.Bot } public async Task HandleMessageAsync(IMessage message) { - var results = await _connection.QueryAsync<PKMember, PKSystem, ProxyDatabaseResult>("select members.*, systems.* from members, systems, accounts where members.system = systems.id and accounts.system = systems.id and accounts.uid = @Uid", (member, system) => new ProxyDatabaseResult { Member = member, System = system }, new { Uid = message.Author.Id }); + var results = await _connection.QueryAsync<PKMember, PKSystem, ProxyDatabaseResult>("select members.*, systems.* from members, systems, accounts where members.system = systems.id and accounts.system = systems.id and accounts.uid = @Uid and (members.prefix != null or members.suffix != null)", (member, system) => new ProxyDatabaseResult { Member = member, System = system }, new { Uid = message.Author.Id }); // Find a member with proxy tags matching the message var match = GetProxyTagMatch(message.Content, results); From 08afa2543b3ca1c467ccbedb8001683fc280c76f Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Fri, 17 May 2019 01:23:09 +0200 Subject: [PATCH 034/103] Add member avatar edit command This also refactors a large portion of the DI toolchain, since I discovered that you shouldn't be reusing IDbConnection objects. Instead, most services and stores are now declared transient, and the webhook cache has been moved to a database-independent storage singleton by itself. --- PluralKit.Bot/Bot.cs | 26 +++++----- PluralKit.Bot/Commands/MemberCommands.cs | 21 ++++++++ PluralKit.Bot/ContextUtils.cs | 24 ++++++++++ PluralKit.Bot/Errors.cs | 8 ++++ PluralKit.Bot/Limits.cs | 3 ++ PluralKit.Bot/PluralKit.Bot.csproj | 2 + PluralKit.Bot/Services/ProxyService.cs | 31 ++---------- PluralKit.Bot/Services/WebhookCacheService.cs | 48 +++++++++++++++++++ PluralKit.Bot/Utils.cs | 46 +++++++++++++++++- 9 files changed, 169 insertions(+), 40 deletions(-) create mode 100644 PluralKit.Bot/Services/WebhookCacheService.cs diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index bd3810f2..a84799d0 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -64,21 +64,23 @@ namespace PluralKit.Bot } public ServiceProvider BuildServiceProvider() => new ServiceCollection() - .AddSingleton(_config.GetSection("PluralKit").Get<CoreConfig>() ?? new CoreConfig()) - .AddSingleton(_config.GetSection("PluralKit").GetSection("Bot").Get<BotConfig>() ?? new BotConfig()) + .AddTransient(_ => _config.GetSection("PluralKit").Get<CoreConfig>() ?? new CoreConfig()) + .AddTransient(_ => _config.GetSection("PluralKit").GetSection("Bot").Get<BotConfig>() ?? new BotConfig()) + + .AddScoped<IDbConnection>(svc => new NpgsqlConnection(svc.GetRequiredService<CoreConfig>().Database)) .AddSingleton<IDiscordClient, DiscordSocketClient>() - .AddSingleton<IDbConnection, NpgsqlConnection>() .AddSingleton<Bot>() - .AddSingleton<CommandService>() - .AddSingleton<EmbedService>() - .AddSingleton<LogChannelService>() - .AddSingleton<ProxyService>() + .AddTransient<CommandService>() + .AddTransient<EmbedService>() + .AddTransient<ProxyService>() + .AddTransient<LogChannelService>() + .AddSingleton<WebhookCacheService>() - .AddSingleton<SystemStore>() - .AddSingleton<MemberStore>() - .AddSingleton<MessageStore>() + .AddTransient<SystemStore>() + .AddTransient<MemberStore>() + .AddTransient<MessageStore>() .BuildServiceProvider(); } class Bot @@ -154,6 +156,8 @@ namespace PluralKit.Bot private async Task MessageReceived(SocketMessage _arg) { + var serviceScope = _services.CreateScope(); + // Ignore system messages (member joined, message pinned, etc) var arg = _arg as SocketUserMessage; if (arg == null) return; @@ -169,7 +173,7 @@ namespace PluralKit.Bot // and start command execution // Note system may be null if user has no system, hence `OrDefault` var system = await _connection.QueryFirstOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id }); - await _commands.ExecuteAsync(new PKCommandContext(_client, arg as SocketUserMessage, _connection, system), argPos, _services); + await _commands.ExecuteAsync(new PKCommandContext(_client, arg, _connection, system), argPos, serviceScope.ServiceProvider); } else { diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index 200b6e95..968c5fc2 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -1,8 +1,12 @@ using System; +using System.Linq; +using System.Net.Http; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Discord; using Discord.Commands; using NodaTime; +using Image = SixLabors.ImageSharp.Image; namespace PluralKit.Bot.Commands { @@ -179,6 +183,22 @@ namespace PluralKit.Bot.Commands await Context.Channel.SendMessageAsync($"{Emojis.Success} Member deleted."); } + [Command("avatar")] + [Alias("profile", "picture", "icon", "image", "pic", "pfp")] + [Remarks("member <member> avatar <avatar url>")] + [MustPassOwnMember] + public async Task MemberAvatar([Remainder] string avatarUrl = null) + { + string url = avatarUrl ?? Context.Message.Attachments.FirstOrDefault()?.ProxyUrl; + if (url != null) await Context.BusyIndicator(() => Utils.VerifyAvatarOrThrow(url)); + + ContextEntity.AvatarUrl = url; + await Members.Save(ContextEntity); + + var embed = url != null ? new EmbedBuilder().WithImageUrl(url).Build() : null; + await Context.Channel.SendMessageAsync($"{Emojis.Success} Member avatar {(url == null ? "cleared" : "changed")}.", embed: embed); + } + [Command] [Alias("view", "show", "info")] [Remarks("member")] @@ -187,6 +207,7 @@ namespace PluralKit.Bot.Commands var system = await Systems.GetById(member.System); await Context.Channel.SendMessageAsync(embed: await Embeds.CreateMemberEmbed(system, member)); } + public override async Task<PKMember> ReadContextParameterAsync(string value) { var res = await new PKMemberTypeReader().ReadAsync(Context, value, _services); diff --git a/PluralKit.Bot/ContextUtils.cs b/PluralKit.Bot/ContextUtils.cs index 1c34a886..39f3ed44 100644 --- a/PluralKit.Bot/ContextUtils.cs +++ b/PluralKit.Bot/ContextUtils.cs @@ -103,5 +103,29 @@ namespace PluralKit.Bot { } public static async Task<bool> HasPermission(this ICommandContext ctx, ChannelPermission permission) => (await Permissions(ctx)).Has(permission); + + public static async Task BusyIndicator(this ICommandContext ctx, Func<Task> f, string emoji = "\u23f3" /* hourglass */) + { + await ctx.BusyIndicator<object>(async () => + { + await f(); + return null; + }, emoji); + } + + public static async Task<T> BusyIndicator<T>(this ICommandContext ctx, Func<Task<T>> f, string emoji = "\u23f3" /* hourglass */) + { + var task = f(); + + try + { + await Task.WhenAll(ctx.Message.AddReactionAsync(new Emoji(emoji)), task); + return await task; + } + finally + { + ctx.Message.RemoveReactionAsync(new Emoji(emoji), ctx.Client.CurrentUser); + } + } } } \ No newline at end of file diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 52ec1539..599db57d 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -1,3 +1,6 @@ +using System.Net; +using Humanizer; + namespace PluralKit.Bot { public static class Errors { // TODO: is returning constructed errors and throwing them at call site a good idea, or should these be methods that insta-throw instead? @@ -20,5 +23,10 @@ namespace PluralKit.Bot { public static PKError ProxyMultipleText => new PKSyntaxError("Example proxy message must contain the string 'text' exactly once."); public static PKError MemberDeleteCancelled => new PKError($"Member deletion cancelled. Stay safe! {Emojis.ThumbsUp}"); + public static PKError AvatarServerError(HttpStatusCode statusCode) => new PKError($"Server responded with status code {(int) statusCode}, are you sure your link is working?"); + public static PKError AvatarFileSizeLimit(long size) => new PKError($"File size too large ({size.Bytes().ToString("#.#")} > {Limits.AvatarFileSizeLimit.Bytes().ToString("#.#")}), try shrinking or compressing the image."); + public static PKError AvatarNotAnImage(string mimeType) => new PKError($"The given link does not point to an image{(mimeType != null ? $" ({mimeType})" : "")}. Make sure you're using a direct link (ending in .jpg, .png, .gif)."); + public static PKError AvatarDimensionsTooLarge(int width, int height) => new PKError($"Image too large ({width}x{height} > {Limits.AvatarDimensionLimit}x{Limits.AvatarDimensionLimit}), try resizing the image."); + public static PKError InvalidUrl(string url) => new PKError($"The given URL is invalid."); } } \ No newline at end of file diff --git a/PluralKit.Bot/Limits.cs b/PluralKit.Bot/Limits.cs index 9971bcfc..3f7b3ec5 100644 --- a/PluralKit.Bot/Limits.cs +++ b/PluralKit.Bot/Limits.cs @@ -5,5 +5,8 @@ namespace PluralKit.Bot { public static readonly int MaxDescriptionLength = 1000; public static readonly int MaxMemberNameLength = 50; public static readonly int MaxPronounsLength = 100; + + public static readonly long AvatarFileSizeLimit = 1024 * 1024; + public static readonly int AvatarDimensionLimit = 1000; } } \ No newline at end of file diff --git a/PluralKit.Bot/PluralKit.Bot.csproj b/PluralKit.Bot/PluralKit.Bot.csproj index 4c7c476f..7525a821 100644 --- a/PluralKit.Bot/PluralKit.Bot.csproj +++ b/PluralKit.Bot/PluralKit.Bot.csproj @@ -13,6 +13,8 @@ <PackageReference Include="Discord.Net.Commands" Version="2.0.1" /> <PackageReference Include="Discord.Net.Webhook" Version="2.0.1" /> <PackageReference Include="Discord.Net.WebSocket" Version="2.0.1" /> + <PackageReference Include="Humanizer.Core" Version="2.6.2" /> + <PackageReference Include="SixLabors.ImageSharp" Version="1.0.0-beta0006" /> </ItemGroup> </Project> diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index e43b8f5d..b58773f3 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -31,18 +31,17 @@ namespace PluralKit.Bot private IDiscordClient _client; private IDbConnection _connection; private LogChannelService _logger; + private WebhookCacheService _webhookCache; private MessageStore _messageStorage; - private ConcurrentDictionary<ulong, Lazy<Task<IWebhook>>> _webhooks; - public ProxyService(IDiscordClient client, IDbConnection connection, LogChannelService logger, MessageStore messageStorage) + public ProxyService(IDiscordClient client, WebhookCacheService webhookCache, IDbConnection connection, LogChannelService logger, MessageStore messageStorage) { this._client = client; + this._webhookCache = webhookCache; this._connection = connection; this._logger = logger; this._messageStorage = messageStorage; - - _webhooks = new ConcurrentDictionary<ulong, Lazy<Task<IWebhook>>>(); } private ProxyMatch GetProxyTagMatch(string message, IEnumerable<ProxyDatabaseResult> potentials) { @@ -70,7 +69,7 @@ namespace PluralKit.Bot if (match == null) return; // Fetch a webhook for this channel, and send the proxied message - var webhook = await GetWebhookByChannelCaching(message.Channel as ITextChannel); + var webhook = await _webhookCache.GetWebhook(message.Channel as ITextChannel); var hookMessage = await ExecuteWebhook(webhook, match.InnerText, match.ProxyName, match.Member.AvatarUrl, message.Attachments.FirstOrDefault()); // Store the message in the database, and log it in the log channel (if applicable) @@ -96,28 +95,6 @@ namespace PluralKit.Bot return await webhook.Channel.GetMessageAsync(messageId); } - private async Task<IWebhook> GetWebhookByChannelCaching(ITextChannel channel) { - // We cache the webhook through a Lazy<Task<T>>, this way we make sure to only create one webhook per channel - // TODO: make sure this is sharding-safe. Intuition says yes, since one channel is guaranteed to only be handled by one shard, but best to make sure - var webhookFactory = _webhooks.GetOrAdd(channel.Id, new Lazy<Task<IWebhook>>(() => FindWebhookByChannel(channel))); - return await webhookFactory.Value; - } - - private async Task<IWebhook> FindWebhookByChannel(ITextChannel channel) { - IWebhook webhook; - - webhook = (await channel.GetWebhooksAsync()).FirstOrDefault(IsWebhookMine); - if (webhook != null) return webhook; - - webhook = await channel.CreateWebhookAsync("PluralKit Proxy Webhook"); - return webhook; - } - - private bool IsWebhookMine(IWebhook arg) - { - return arg.Creator.Id == this._client.CurrentUser.Id && arg.Name == "PluralKit Proxy Webhook"; - } - public async Task HandleReactionAddedAsync(Cacheable<IUserMessage, ulong> message, ISocketMessageChannel channel, SocketReaction reaction) { // Make sure it's the right emoji (red X) diff --git a/PluralKit.Bot/Services/WebhookCacheService.cs b/PluralKit.Bot/Services/WebhookCacheService.cs new file mode 100644 index 00000000..e45597d0 --- /dev/null +++ b/PluralKit.Bot/Services/WebhookCacheService.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.WebSocket; + +namespace PluralKit.Bot +{ + public class WebhookCacheService + { + public static readonly string WebhookName = "PluralKit Proxy Webhook"; + + private IDiscordClient _client; + private ConcurrentDictionary<ulong, Lazy<Task<IWebhook>>> _webhooks; + + public WebhookCacheService(IDiscordClient client) + { + this._client = client; + _webhooks = new ConcurrentDictionary<ulong, Lazy<Task<IWebhook>>>(); + } + + public async Task<IWebhook> GetWebhook(ulong channelId) + { + var channel = await _client.GetChannelAsync(channelId) as ITextChannel; + if (channel == null) return null; + return await GetWebhook(channel); + } + + public async Task<IWebhook> GetWebhook(ITextChannel channel) + { + // We cache the webhook through a Lazy<Task<T>>, this way we make sure to only create one webhook per channel + // If the webhook is requested twice before it's actually been found, the Lazy<T> wrapper will stop the + // webhook from being created twice. + var lazyWebhookValue = + _webhooks.GetOrAdd(channel.Id, new Lazy<Task<IWebhook>>(() => GetOrCreateWebhook(channel))); + return await lazyWebhookValue.Value; + } + + private async Task<IWebhook> GetOrCreateWebhook(ITextChannel channel) => + await FindExistingWebhook(channel) ?? await GetOrCreateWebhook(channel); + + private async Task<IWebhook> FindExistingWebhook(ITextChannel channel) => (await channel.GetWebhooksAsync()).FirstOrDefault(IsWebhookMine); + + private async Task<IWebhook> DoCreateWebhook(ITextChannel channel) => await channel.CreateWebhookAsync(WebhookName); + private bool IsWebhookMine(IWebhook arg) => arg.Creator.Id == _client.CurrentUser.Id && arg.Name == WebhookName; + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index a9b9dfa1..365b2ac6 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -1,7 +1,7 @@ using System; -using System.Collections.Generic; using System.Data; using System.Linq; +using System.Net.Http; using System.Threading.Tasks; using Dapper; using Discord; @@ -9,7 +9,7 @@ using Discord.Commands; using Discord.Commands.Builders; using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; -using NodaTime; +using Image = SixLabors.ImageSharp.Image; namespace PluralKit.Bot { @@ -17,6 +17,48 @@ namespace PluralKit.Bot public static string NameAndMention(this IUser user) { return $"{user.Username}#{user.Discriminator} ({user.Mention})"; } + + public static async Task VerifyAvatarOrThrow(string url) + { + // List of MIME types we consider acceptable + var acceptableMimeTypes = new[] + { + "image/jpeg", + "image/gif", + "image/png" + // TODO: add image/webp once ImageSharp supports this + }; + + using (var client = new HttpClient()) + { + Uri uri; + try + { + uri = new Uri(url); + if (!uri.IsAbsoluteUri) throw Errors.InvalidUrl(url); + } + catch (UriFormatException) + { + throw Errors.InvalidUrl(url); + } + + var response = await client.GetAsync(uri); + if (!response.IsSuccessStatusCode) // Check status code + throw Errors.AvatarServerError(response.StatusCode); + if (response.Content.Headers.ContentLength == null) // Check presence of content length + throw Errors.AvatarNotAnImage(null); + if (response.Content.Headers.ContentLength > Limits.AvatarFileSizeLimit) // Check content length + throw Errors.AvatarFileSizeLimit(response.Content.Headers.ContentLength.Value); + if (!acceptableMimeTypes.Contains(response.Content.Headers.ContentType.MediaType)) // Check MIME type + throw Errors.AvatarNotAnImage(response.Content.Headers.ContentType.MediaType); + + // Parse the image header in a worker + var stream = await response.Content.ReadAsStreamAsync(); + var image = await Task.Run(() => Image.Identify(stream)); + if (image.Width > Limits.AvatarDimensionLimit || image.Height > Limits.AvatarDimensionLimit) // Check image size + throw Errors.AvatarDimensionsTooLarge(image.Width, image.Height); + } + } } class UlongEncodeAsLongHandler : SqlMapper.TypeHandler<ulong> From 8b8ec8094435bc5d29cfbe40ee28657ada05a674 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sun, 19 May 2019 22:03:28 +0200 Subject: [PATCH 035/103] Move database/mapper setup code to Core --- PluralKit.Bot/Bot.cs | 17 ++-------- PluralKit.Bot/Utils.cs | 27 ---------------- PluralKit.Core/DatabaseUtils.cs | 55 +++++++++++++++++++++++++++++++++ PluralKit.Web/Startup.cs | 8 +++-- 4 files changed, 62 insertions(+), 45 deletions(-) create mode 100644 PluralKit.Core/DatabaseUtils.cs diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index a84799d0..cefdf524 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -27,21 +27,8 @@ namespace PluralKit.Bot private async Task MainAsync() { Console.WriteLine("Starting PluralKit..."); - - // Dapper by default tries to pass ulongs to Npgsql, which rejects them since PostgreSQL technically - // doesn't support unsigned types on its own. - // Instead we add a custom mapper to encode them as signed integers instead, converting them back and forth. - SqlMapper.RemoveTypeMap(typeof(ulong)); - SqlMapper.AddTypeHandler<ulong>(new UlongEncodeAsLongHandler()); - Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; - - // Also, use NodaTime. it's good. - NpgsqlConnection.GlobalTypeMapper.UseNodaTime(); - // With the thing we add above, Npgsql already handles NodaTime integration - // This makes Dapper confused since it thinks it has to convert it anyway and doesn't understand the types - // So we add a custom type handler that literally just passes the type through to Npgsql - SqlMapper.AddTypeHandler(new PassthroughTypeHandler<Instant>()); - SqlMapper.AddTypeHandler(new PassthroughTypeHandler<LocalDate>()); + + DatabaseUtils.Init(); using (var services = BuildServiceProvider()) { diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index 365b2ac6..68c6457c 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -61,33 +61,6 @@ namespace PluralKit.Bot } } - 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 PassthroughTypeHandler<T> : SqlMapper.TypeHandler<T> - { - public override void SetValue(IDbDataParameter parameter, T value) - { - parameter.Value = value; - } - - public override T Parse(object value) - { - return (T) value; - } - } - class PKSystemTypeReader : TypeReader { public override async Task<TypeReaderResult> ReadAsync(ICommandContext context, string input, IServiceProvider services) diff --git a/PluralKit.Core/DatabaseUtils.cs b/PluralKit.Core/DatabaseUtils.cs new file mode 100644 index 00000000..06bb76a9 --- /dev/null +++ b/PluralKit.Core/DatabaseUtils.cs @@ -0,0 +1,55 @@ +using System.Data; +using Dapper; +using NodaTime; +using Npgsql; + +namespace PluralKit +{ + public static class DatabaseUtils + { + public static void Init() + { + // Dapper by default tries to pass ulongs to Npgsql, which rejects them since PostgreSQL technically + // doesn't support unsigned types on its own. + // Instead we add a custom mapper to encode them as signed integers instead, converting them back and forth. + SqlMapper.RemoveTypeMap(typeof(ulong)); + SqlMapper.AddTypeHandler<ulong>(new UlongEncodeAsLongHandler()); + Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + + // Also, use NodaTime. it's good. + NpgsqlConnection.GlobalTypeMapper.UseNodaTime(); + // With the thing we add above, Npgsql already handles NodaTime integration + // This makes Dapper confused since it thinks it has to convert it anyway and doesn't understand the types + // So we add a custom type handler that literally just passes the type through to Npgsql + SqlMapper.AddTypeHandler(new PassthroughTypeHandler<Instant>()); + SqlMapper.AddTypeHandler(new PassthroughTypeHandler<LocalDate>()); + } + + 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 PassthroughTypeHandler<T> : SqlMapper.TypeHandler<T> + { + public override void SetValue(IDbDataParameter parameter, T value) + { + parameter.Value = value; + } + + public override T Parse(object value) + { + return (T) value; + } + } + } +} \ No newline at end of file diff --git a/PluralKit.Web/Startup.cs b/PluralKit.Web/Startup.cs index 3b201fd6..df339377 100644 --- a/PluralKit.Web/Startup.cs +++ b/PluralKit.Web/Startup.cs @@ -20,14 +20,16 @@ namespace PluralKit.Web // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { + DatabaseUtils.Init(); + var config = Configuration.GetSection("PluralKit").Get<CoreConfig>(); services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); services - .AddSingleton<IDbConnection, NpgsqlConnection>(_ => new NpgsqlConnection(config.Database)) - .AddSingleton<SystemStore>() - .AddSingleton<MemberStore>() + .AddScoped<IDbConnection, NpgsqlConnection>(_ => new NpgsqlConnection(config.Database)) + .AddTransient<SystemStore>() + .AddTransient<MemberStore>() .AddSingleton(config); } From 4c6790432b3d1880543bee0aa024b33ed067edb7 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Tue, 21 May 2019 23:40:26 +0200 Subject: [PATCH 036/103] Add system linking commands --- PluralKit.Bot/Bot.cs | 3 ++ PluralKit.Bot/Commands/LinkCommands.cs | 50 ++++++++++++++++++++++++ PluralKit.Bot/Commands/MiscCommands.cs | 2 +- PluralKit.Bot/Commands/SystemCommands.cs | 2 +- PluralKit.Bot/ContextUtils.cs | 4 +- PluralKit.Bot/Errors.cs | 7 ++++ PluralKit.Bot/Utils.cs | 12 +++--- PluralKit.Core/Stores.cs | 6 ++- 8 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 PluralKit.Bot/Commands/LinkCommands.cs diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index cefdf524..73be80cc 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -130,6 +130,9 @@ namespace PluralKit.Bot await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {exception.Message}"); } else if (exception is TimeoutException) { await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} Operation timed out. Try being faster next time :)"); + } else if (_result is PreconditionResult) + { + await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}"); } else { HandleRuntimeError((_result as ExecuteResult?)?.Exception); } diff --git a/PluralKit.Bot/Commands/LinkCommands.cs b/PluralKit.Bot/Commands/LinkCommands.cs new file mode 100644 index 00000000..cae4e3c6 --- /dev/null +++ b/PluralKit.Bot/Commands/LinkCommands.cs @@ -0,0 +1,50 @@ +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.Commands; + +namespace PluralKit.Bot.Commands +{ + public class LinkCommands: ModuleBase<PKCommandContext> + { + public SystemStore Systems { get; set; } + + + [Command("link")] + [Remarks("link <account>")] + [MustHaveSystem] + public async Task LinkSystem(IUser account) + { + var accountIds = await Systems.GetLinkedAccountIds(Context.SenderSystem); + if (accountIds.Contains(account.Id)) throw Errors.AccountAlreadyLinked; + + var existingAccount = await Systems.GetByAccount(account.Id); + if (existingAccount != null) throw Errors.AccountInOtherSystem(existingAccount); + + var msg = await Context.Channel.SendMessageAsync( + $"{account.Mention}, please confirm the link by clicking the {Emojis.Success} reaction on this message."); + if (!await Context.PromptYesNo(msg, user: account)) throw Errors.MemberLinkCancelled; + await Systems.Link(Context.SenderSystem, account.Id); + await Context.Channel.SendMessageAsync($"{Emojis.Success} Account linked to system."); + } + + [Command("unlink")] + [Remarks("unlink [account]")] + [MustHaveSystem] + public async Task UnlinkAccount(IUser account = null) + { + if (account == null) account = Context.User; + + var accountIds = (await Systems.GetLinkedAccountIds(Context.SenderSystem)).ToList(); + if (!accountIds.Contains(account.Id)) throw Errors.AccountNotLinked; + if (accountIds.Count == 1) throw Errors.UnlinkingLastAccount; + + var msg = await Context.Channel.SendMessageAsync( + $"Are you sure you want to unlink {account.Mention} from your system?"); + if (!await Context.PromptYesNo(msg)) throw Errors.MemberUnlinkCancelled; + + await Systems.Unlink(Context.SenderSystem, account.Id); + await Context.Channel.SendMessageAsync($"{Emojis.Success} Account unlinked."); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/MiscCommands.cs b/PluralKit.Bot/Commands/MiscCommands.cs index e44d9f39..d678c701 100644 --- a/PluralKit.Bot/Commands/MiscCommands.cs +++ b/PluralKit.Bot/Commands/MiscCommands.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; using Discord; using Discord.Commands; -namespace PluralKit.Bot { +namespace PluralKit.Bot.Commands { public class MiscCommands: ModuleBase<PKCommandContext> { [Command("invite")] [Remarks("invite")] diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index 82b16e11..031ce9cf 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -20,7 +20,7 @@ namespace PluralKit.Bot.Commands [Command] public async Task Query(PKSystem system = null) { if (system == null) system = Context.SenderSystem; - if (system == null) throw Errors.NotOwnSystemError; + if (system == null) throw Errors.NoSystemError; await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateSystemEmbed(system)); } diff --git a/PluralKit.Bot/ContextUtils.cs b/PluralKit.Bot/ContextUtils.cs index 39f3ed44..9d9f448e 100644 --- a/PluralKit.Bot/ContextUtils.cs +++ b/PluralKit.Bot/ContextUtils.cs @@ -8,9 +8,9 @@ using Discord.WebSocket; namespace PluralKit.Bot { public static class ContextUtils { - public static async Task<bool> PromptYesNo(this ICommandContext ctx, IUserMessage message, TimeSpan? timeout = null) { + public static async Task<bool> PromptYesNo(this ICommandContext ctx, IUserMessage message, IUser user = null, TimeSpan? timeout = null) { await message.AddReactionsAsync(new[] {new Emoji(Emojis.Success), new Emoji(Emojis.Error)}); - var reaction = await ctx.AwaitReaction(message, ctx.User, (r) => r.Emote.Name == Emojis.Success || r.Emote.Name == Emojis.Error, timeout ?? TimeSpan.FromMinutes(1)); + var reaction = await ctx.AwaitReaction(message, user ?? ctx.User, (r) => r.Emote.Name == Emojis.Success || r.Emote.Name == Emojis.Error, timeout ?? TimeSpan.FromMinutes(1)); return reaction.Emote.Name == Emojis.Success; } diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 599db57d..e79f77c3 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -28,5 +28,12 @@ namespace PluralKit.Bot { public static PKError AvatarNotAnImage(string mimeType) => new PKError($"The given link does not point to an image{(mimeType != null ? $" ({mimeType})" : "")}. Make sure you're using a direct link (ending in .jpg, .png, .gif)."); public static PKError AvatarDimensionsTooLarge(int width, int height) => new PKError($"Image too large ({width}x{height} > {Limits.AvatarDimensionLimit}x{Limits.AvatarDimensionLimit}), try resizing the image."); public static PKError InvalidUrl(string url) => new PKError($"The given URL is invalid."); + + public static PKError AccountAlreadyLinked => new PKError("That account is already linked to your system."); + public static PKError AccountNotLinked => new PKError("That account isn't linked to your system."); + public static PKError AccountInOtherSystem(PKSystem system) => new PKError($"The mentioned account is already linked to another system (see `pk;system {system.Hid}`)."); + public static PKError UnlinkingLastAccount => new PKError("Since this is the only account linked to this system, you cannot unlink it (as that would leave your system account-less)."); + public static PKError MemberLinkCancelled => new PKError("Member link cancelled."); + public static PKError MemberUnlinkCancelled => new PKError("Member unlink cancelled."); } } \ No newline at end of file diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index 68c6457c..4d1d0bbf 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -66,7 +66,7 @@ namespace PluralKit.Bot public override async Task<TypeReaderResult> ReadAsync(ICommandContext context, string input, IServiceProvider services) { var client = services.GetService<IDiscordClient>(); - var conn = services.GetService<IDbConnection>(); + var systems = services.GetService<SystemStore>(); // System references can take three forms: // - The direct user ID of an account connected to the system @@ -74,20 +74,20 @@ namespace PluralKit.Bot // - A system hid // First, try direct user ID parsing - if (ulong.TryParse(input, out var idFromNumber)) return await FindSystemByAccountHelper(idFromNumber, client, conn); + if (ulong.TryParse(input, out var idFromNumber)) return await FindSystemByAccountHelper(idFromNumber, client, systems); // Then, try mention parsing. - if (MentionUtils.TryParseUser(input, out var idFromMention)) return await FindSystemByAccountHelper(idFromMention, client, conn); + if (MentionUtils.TryParseUser(input, out var idFromMention)) return await FindSystemByAccountHelper(idFromMention, client, systems); // Finally, try HID parsing - var res = await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where hid = @Hid", new { Hid = input }); + var res = await systems.GetByHid(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) + async Task<TypeReaderResult> FindSystemByAccountHelper(ulong id, IDiscordClient client, SystemStore systems) { - var foundByAccountId = await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from accounts, systems where accounts.system = system.id and accounts.id = @Id", new { Id = id }); + var foundByAccountId = await systems.GetByAccount(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, diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index 5b27b5b7..9cca11fa 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -23,9 +23,13 @@ namespace PluralKit { 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 Unlink(PKSystem system, ulong accountId) { + await conn.ExecuteAsync("delete from accounts where uid = @Id and system = @SystemId", new { Id = accountId, SystemId = system.Id }); + } public async Task<PKSystem> GetByAccount(ulong accountId) { - return await conn.QuerySingleOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.system = system.id and accounts.uid = @Id", new { Id = accountId }); + return await conn.QuerySingleOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.system = systems.id and accounts.uid = @Id", new { Id = accountId }); } public async Task<PKSystem> GetByHid(string hid) { From 7e9b7dcc983672849c68955f5bd40380328ca3de Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Thu, 13 Jun 2019 16:53:04 +0200 Subject: [PATCH 037/103] Add switch commands for adding and moving --- PluralKit.Bot/Bot.cs | 14 ++- PluralKit.Bot/Commands/SwitchCommands.cs | 92 +++++++++++++++++++ PluralKit.Bot/Errors.cs | 20 +++++ PluralKit.Bot/Utils.cs | 2 +- PluralKit.Core/Models.cs | 17 +++- PluralKit.Core/Stores.cs | 57 ++++++++++++ PluralKit.Core/Utils.cs | 110 ++++++++++++++++++++++- 7 files changed, 302 insertions(+), 10 deletions(-) create mode 100644 PluralKit.Bot/Commands/SwitchCommands.cs diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 73be80cc..a8dc540a 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -1,5 +1,6 @@ using System; using System.Data; +using System.Diagnostics; using System.Linq; using System.Reflection; using System.Threading; @@ -34,8 +35,6 @@ namespace PluralKit.Bot { Console.WriteLine("- Connecting to database..."); var connection = services.GetRequiredService<IDbConnection>() as NpgsqlConnection; - connection.ConnectionString = services.GetRequiredService<CoreConfig>().Database; - await connection.OpenAsync(); await Schema.CreateTables(connection); Console.WriteLine("- Connecting to Discord..."); @@ -54,7 +53,13 @@ namespace PluralKit.Bot .AddTransient(_ => _config.GetSection("PluralKit").Get<CoreConfig>() ?? new CoreConfig()) .AddTransient(_ => _config.GetSection("PluralKit").GetSection("Bot").Get<BotConfig>() ?? new BotConfig()) - .AddScoped<IDbConnection>(svc => new NpgsqlConnection(svc.GetRequiredService<CoreConfig>().Database)) + .AddScoped<IDbConnection>(svc => + { + + var conn = new NpgsqlConnection(svc.GetRequiredService<CoreConfig>().Database); + conn.Open(); + return conn; + }) .AddSingleton<IDiscordClient, DiscordSocketClient>() .AddSingleton<Bot>() @@ -68,6 +73,7 @@ namespace PluralKit.Bot .AddTransient<SystemStore>() .AddTransient<MemberStore>() .AddTransient<MessageStore>() + .AddTransient<SwitchStore>() .BuildServiceProvider(); } class Bot @@ -138,7 +144,7 @@ namespace PluralKit.Bot } } else if ((_result.Error == CommandError.BadArgCount || _result.Error == CommandError.MultipleMatches) && cmd.IsSpecified) { await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}\n**Usage: **pk;{cmd.Value.Remarks}"); - } else if (_result.Error == CommandError.UnknownCommand || _result.Error == CommandError.UnmetPrecondition) { + } else if (_result.Error == CommandError.UnknownCommand || _result.Error == CommandError.UnmetPrecondition || _result.Error == CommandError.ObjectNotFound) { await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}"); } } diff --git a/PluralKit.Bot/Commands/SwitchCommands.cs b/PluralKit.Bot/Commands/SwitchCommands.cs new file mode 100644 index 00000000..37adfeb0 --- /dev/null +++ b/PluralKit.Bot/Commands/SwitchCommands.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.Commands; +using NodaTime; +using NodaTime.TimeZones; + +namespace PluralKit.Bot.Commands +{ + [Group("switch")] + public class SwitchCommands: ModuleBase<PKCommandContext> + { + public SystemStore Systems { get; set; } + public SwitchStore Switches { get; set; } + + [Command] + [Remarks("switch <member> [member...]")] + [MustHaveSystem] + public async Task Switch(params PKMember[] members) => await DoSwitchCommand(members); + + [Command("out")] + [MustHaveSystem] + public async Task SwitchOut() => await DoSwitchCommand(new PKMember[] { }); + + private async Task DoSwitchCommand(ICollection<PKMember> members) + { + // Make sure there are no dupes in the list + // We do this by checking if removing duplicate member IDs results in a list of different length + if (members.Select(m => m.Id).Distinct().Count() != members.Count) throw Errors.DuplicateSwitchMembers; + + // Find the last switch and its members if applicable + var lastSwitch = await Switches.GetLatestSwitch(Context.SenderSystem); + if (lastSwitch != null) + { + var lastSwitchMembers = await Switches.GetSwitchMembers(lastSwitch); + // Make sure the requested switch isn't identical to the last one + if (lastSwitchMembers.Select(m => m.Id).SequenceEqual(members.Select(m => m.Id))) + throw Errors.SameSwitch(members); + } + + await Switches.RegisterSwitch(Context.SenderSystem, members); + + if (members.Count == 0) + await Context.Channel.SendMessageAsync($"{Emojis.Success} Switch-out registered."); + else + await Context.Channel.SendMessageAsync($"{Emojis.Success} Switch registered. Current fronter is now {string.Join(", ", members.Select(m => m.Name))}."); + } + + [Command("move")] + [MustHaveSystem] + public async Task SwitchMove([Remainder] string str) + { + var tz = TzdbDateTimeZoneSource.Default.ForId(Context.SenderSystem.UiTz ?? "UTC"); + + var result = PluralKit.Utils.ParseDateTime(str, true, tz); + if (result == null) throw Errors.InvalidDateTime(str); + + var time = result.Value; + if (time.ToInstant() > SystemClock.Instance.GetCurrentInstant()) throw Errors.SwitchTimeInFuture; + + // Fetch the last two switches for the system to do bounds checking on + var lastTwoSwitches = (await Switches.GetSwitches(Context.SenderSystem, 2)).ToArray(); + + // If we don't have a switch to move, don't bother + if (lastTwoSwitches.Length == 0) throw Errors.NoRegisteredSwitches; + + // If there's a switch *behind* the one we move, we check to make srue we're not moving the time further back than that + if (lastTwoSwitches.Length == 2) + { + if (lastTwoSwitches[1].Timestamp > time.ToInstant()) + throw Errors.SwitchMoveBeforeSecondLast(lastTwoSwitches[1].Timestamp.InZone(tz)); + } + + // Now we can actually do the move, yay! + // But, we do a prompt to confirm. + var lastSwitchMembers = await Switches.GetSwitchMembers(lastTwoSwitches[0]); + + // yeet + var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} This will move the latest switch ({string.Join(", ", lastSwitchMembers.Select(m => m.Name))}) from {lastTwoSwitches[0].Timestamp.ToString(Formats.DateTimeFormat, null)} ({SystemClock.Instance.GetCurrentInstant().Minus(lastTwoSwitches[0].Timestamp).ToString(Formats.DurationFormat, null)} ago) to {time.ToString(Formats.DateTimeFormat, null)} ({SystemClock.Instance.GetCurrentInstant().Minus(time.ToInstant()).ToString(Formats.DurationFormat, null)} ago). Is this OK?"); + if (!await Context.PromptYesNo(msg)) + { + throw Errors.SwitchMoveCancelled; + } + + // aaaand *now* we do the move + await Switches.MoveSwitch(lastTwoSwitches[0], time.ToInstant()); + await Context.Channel.SendMessageAsync($"{Emojis.Success} Switch moved."); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index e79f77c3..5ebf48c4 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -1,5 +1,9 @@ +using System; +using System.Collections.Generic; +using System.Linq; using System.Net; using Humanizer; +using NodaTime; namespace PluralKit.Bot { public static class Errors { @@ -35,5 +39,21 @@ namespace PluralKit.Bot { public static PKError UnlinkingLastAccount => new PKError("Since this is the only account linked to this system, you cannot unlink it (as that would leave your system account-less)."); public static PKError MemberLinkCancelled => new PKError("Member link cancelled."); public static PKError MemberUnlinkCancelled => new PKError("Member unlink cancelled."); + + public static PKError SameSwitch(ICollection<PKMember> members) + { + if (members.Count == 0) return new PKError("There's already no one in front."); + if (members.Count == 1) return new PKError($"Member {members.First().Name} is already fronting."); + return new PKError($"Members {string.Join(", ", members.Select(m => m.Name))} are already fronting."); + } + + public static PKError DuplicateSwitchMembers => new PKError("Duplicate members in member list."); + + public static PKError InvalidDateTime(string str) => new PKError($"Could not parse '{str}' as a valid date/time."); + public static PKError SwitchTimeInFuture => new PKError("Can't move switch to a time in the future."); + public static PKError NoRegisteredSwitches => new PKError("There are no registered switches for this system."); + + public static PKError SwitchMoveBeforeSecondLast(ZonedDateTime time) => new PKError($"Can't move switch to before last switch time ({time.ToString(Formats.DateTimeFormat, null)}), as it would cause conflicts."); + public static PKError SwitchMoveCancelled => new PKError("Switch move cancelled."); } } \ No newline at end of file diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index 4d1d0bbf..3fea795c 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -118,7 +118,7 @@ namespace PluralKit.Bot // do a standard by-hid search. var foundByHid = await members.GetByHid(input); if (foundByHid != null) return TypeReaderResult.FromSuccess(foundByHid); - return TypeReaderResult.FromError(CommandError.ObjectNotFound, "Member not found."); + return TypeReaderResult.FromError(CommandError.ObjectNotFound, $"Member '{input}' not found."); } } diff --git a/PluralKit.Core/Models.cs b/PluralKit.Core/Models.cs index 98031ad4..e4989b6f 100644 --- a/PluralKit.Core/Models.cs +++ b/PluralKit.Core/Models.cs @@ -1,11 +1,9 @@ -using System; using Dapper.Contrib.Extensions; using NodaTime; using NodaTime.Text; namespace PluralKit { - [Table("systems")] public class PKSystem { [Key] @@ -22,7 +20,6 @@ namespace PluralKit public int MaxMemberNameLength => Tag != null ? 32 - Tag.Length - 1 : 32; } - [Table("members")] public class PKMember { public int Id { get; set; } @@ -54,4 +51,18 @@ namespace PluralKit public bool HasProxyTags => Prefix != null || Suffix != null; public string ProxyString => $"{Prefix ?? ""}text{Suffix ?? ""}"; } + + public class PKSwitch + { + public int Id { get; set; } + public int System { get; set; } + public Instant Timestamp { get; set; } + } + + public class PKSwitchMember + { + public int Id { get; set; } + public int Switch { get; set; } + public int Member { get; set; } + } } \ No newline at end of file diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index 9cca11fa..334667a1 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -1,10 +1,12 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Data; using System.Linq; using System.Threading.Tasks; using Dapper; using Dapper.Contrib.Extensions; +using NodaTime; namespace PluralKit { public class SystemStore { @@ -147,4 +149,59 @@ namespace PluralKit { await _connection.ExecuteAsync("delete from messages where mid = @Id", new { Id = id }); } } + + public class SwitchStore + { + private IDbConnection _connection; + + public SwitchStore(IDbConnection connection) + { + _connection = connection; + } + + public async Task RegisterSwitch(PKSystem system, IEnumerable<PKMember> members) + { + // Use a transaction here since we're doing multiple executed commands in one + using (var tx = _connection.BeginTransaction()) + { + // First, we insert the switch itself + var sw = await _connection.QuerySingleAsync<PKSwitch>("insert into switches(system) values (@System) returning *", + new {System = system.Id}); + + // Then we insert each member in the switch in the switch_members table + // TODO: can we parallelize this or send it in bulk somehow? + foreach (var member in members) + { + await _connection.ExecuteAsync( + "insert into switch_members(switch, member) values(@Switch, @Member)", + new {Switch = sw.Id, Member = member.Id}); + } + + // Finally we commit the tx, since the using block will otherwise rollback it + tx.Commit(); + } + } + + public async Task<IEnumerable<PKSwitch>> GetSwitches(PKSystem system, int count) + { + // TODO: refactor the PKSwitch data structure to somehow include a hydrated member list + // (maybe when we get caching in?) + return await _connection.QueryAsync<PKSwitch>("select * from switches where system = @System order by timestamp desc limit @Count", new {System = system.Id, Count = count}); + } + + public async Task<IEnumerable<PKMember>> GetSwitchMembers(PKSwitch sw) + { + return await _connection.QueryAsync<PKMember>( + "select * from switch_members, members where switch_members.member = members.id and switch_members.switch = @Switch", + new {Switch = sw.Id}); + } + + public async Task<PKSwitch> GetLatestSwitch(PKSystem system) => (await GetSwitches(system, 1)).FirstOrDefault(); + + public async Task MoveSwitch(PKSwitch sw, Instant time) + { + await _connection.ExecuteAsync("update switches set timestamp = @Time where id = @Id", + new {Time = time, Id = sw.Id}); + } + } } \ No newline at end of file diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index 61972e54..d9539bd4 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -40,7 +40,7 @@ namespace PluralKit Duration d = Duration.Zero; - foreach (Match match in Regex.Matches(str, "(\\d{1,3})(\\w)")) + foreach (Match match in Regex.Matches(str, "(\\d{1,6})(\\w)")) { var amount = int.Parse(match.Groups[1].Value); var type = match.Groups[2].Value; @@ -71,7 +71,7 @@ namespace PluralKit "MMMM d, yyyy", // January 1, 2019 "yyyy-MM-dd", // 2019-01-01 "yyyy MM dd", // 2019 01 01 - "yyyy/MM/dd" // 2019/01/01 + "yyyy/MM/dd" // 2019/01/01 }.ToList(); if (allowNullYear) patterns.AddRange(new[] @@ -94,6 +94,106 @@ namespace PluralKit return null; } + + public static ZonedDateTime? ParseDateTime(string str, bool nudgeToPast = false, DateTimeZone zone = null) + { + if (zone == null) zone = DateTimeZone.Utc; + + // Find the current timestamp in the given zone, find the (naive) midnight timestamp, then put that into the same zone (and make it naive again) + // Should yield a <current *local @ zone* date> 12:00:00 AM. + var now = SystemClock.Instance.GetCurrentInstant().InZone(zone).LocalDateTime; + var midnight = now.Date.AtMidnight(); + + // First we try to parse the string as a relative time using the period parser + var relResult = ParsePeriod(str); + if (relResult != null) + { + // if we can, we just subtract that amount from the + return now.InZoneLeniently(zone).Minus(relResult.Value); + } + + var timePatterns = new[] + { + "H:mm", // 4:30 + "HH:mm", // 23:30 + "H:mm:ss", // 4:30:29 + "HH:mm:ss", // 23:30:29 + "h tt", // 2 PM + "htt", // 2PM + "h:mm tt", // 4:30 PM + "h:mmtt", // 4:30PM + "h:mm:ss tt", // 4:30:29 PM + "h:mm:sstt", // 4:30:29PM + "hh:mm tt", // 11:30 PM + "hh:mmtt", // 11:30PM + "hh:mm:ss tt", // 11:30:29 PM + "hh:mm:sstt" // 11:30:29PM + }; + + var datePatterns = new[] + { + "MMM d yyyy", // Jan 1 2019 + "MMM d, yyyy", // Jan 1, 2019 + "MMMM d yyyy", // January 1 2019 + "MMMM d, yyyy", // January 1, 2019 + "yyyy-MM-dd", // 2019-01-01 + "yyyy MM dd", // 2019 01 01 + "yyyy/MM/dd", // 2019/01/01 + "MMM d", // Jan 1 + "MMMM d", // January 1 + "MM-dd", // 01-01 + "MM dd", // 01 01 + "MM/dd" // 01-01 + }; + + // First, we try all the timestamps that only have a time + foreach (var timePattern in timePatterns) + { + var pat = LocalDateTimePattern.CreateWithInvariantCulture(timePattern).WithTemplateValue(midnight); + var result = pat.Parse(str); + if (result.Success) + { + // If we have a successful match and we need a time in the past, we try to shove a future-time a date before + // Example: "4:30 pm" at 3:30 pm likely refers to 4:30 pm the previous day + var val = result.Value; + + // If we need to nudge, we just subtract a day. This only occurs when we're parsing specifically *just time*, so + // we know we won't nudge it by more than a day since we use today's midnight timestamp as a date template. + + // Since this is a naive datetime, this ensures we're actually moving by one calendar day even if + // DST changes occur, since they'll be resolved later wrt. the right side of the boundary + if (val > now && nudgeToPast) val = val.PlusDays(-1); + return val.InZoneLeniently(zone); + } + } + + // Then we try specific date+time combinations, both date first and time first + foreach (var timePattern in timePatterns) + { + foreach (var datePattern in datePatterns) + { + var p1 = LocalDateTimePattern.CreateWithInvariantCulture($"{timePattern} {datePattern}").WithTemplateValue(midnight); + var res1 = p1.Parse(str); + if (res1.Success) return res1.Value.InZoneLeniently(zone); + + + var p2 = LocalDateTimePattern.CreateWithInvariantCulture($"{datePattern} {timePattern}").WithTemplateValue(midnight); + var res2 = p2.Parse(str); + if (res2.Success) return res2.Value.InZoneLeniently(zone); + } + } + + // Finally, just date patterns, still using midnight as the template + foreach (var datePattern in datePatterns) + { + var pat = LocalDateTimePattern.CreateWithInvariantCulture(datePattern).WithTemplateValue(midnight); + var res = pat.Parse(str); + if (res.Success) return res.Value.InZoneLeniently(zone); + } + + // Still haven't parsed something, we just give up lmao + return null; + } } public static class Emojis { @@ -103,4 +203,10 @@ namespace PluralKit public static readonly string Note = "\u2757"; public static readonly string ThumbsUp = "\U0001f44d"; } + + public static class Formats + { + public static string DateTimeFormat = "yyyy-MM-dd HH-mm-ss"; + public static string DurationFormat = "D'd' h'h' m'm' s's'"; + } } \ No newline at end of file From d109ca7b5723c462a18cb3bc4a4f4479d8eb3ffe Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Thu, 13 Jun 2019 17:05:50 +0200 Subject: [PATCH 038/103] Add switch deletion command --- PluralKit.Bot/Commands/SwitchCommands.cs | 45 +++++++++++++++++++++--- PluralKit.Bot/Errors.cs | 1 + PluralKit.Bot/Utils.cs | 2 +- PluralKit.Core/Stores.cs | 5 +++ 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/PluralKit.Bot/Commands/SwitchCommands.cs b/PluralKit.Bot/Commands/SwitchCommands.cs index 37adfeb0..aaa55d8a 100644 --- a/PluralKit.Bot/Commands/SwitchCommands.cs +++ b/PluralKit.Bot/Commands/SwitchCommands.cs @@ -76,17 +76,52 @@ namespace PluralKit.Bot.Commands // Now we can actually do the move, yay! // But, we do a prompt to confirm. var lastSwitchMembers = await Switches.GetSwitchMembers(lastTwoSwitches[0]); + var lastSwitchMemberStr = string.Join(", ", lastSwitchMembers.Select(m => m.Name)); + var lastSwitchTimeStr = lastTwoSwitches[0].Timestamp.ToString(Formats.DateTimeFormat, null); + var lastSwitchDeltaStr = SystemClock.Instance.GetCurrentInstant().Minus(lastTwoSwitches[0].Timestamp).ToString(Formats.DurationFormat, null); + var newSwitchTimeStr = time.ToString(Formats.DateTimeFormat, null); + var newSwitchDeltaStr = SystemClock.Instance.GetCurrentInstant().Minus(time.ToInstant()).ToString(Formats.DurationFormat, null); // yeet - var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} This will move the latest switch ({string.Join(", ", lastSwitchMembers.Select(m => m.Name))}) from {lastTwoSwitches[0].Timestamp.ToString(Formats.DateTimeFormat, null)} ({SystemClock.Instance.GetCurrentInstant().Minus(lastTwoSwitches[0].Timestamp).ToString(Formats.DurationFormat, null)} ago) to {time.ToString(Formats.DateTimeFormat, null)} ({SystemClock.Instance.GetCurrentInstant().Minus(time.ToInstant()).ToString(Formats.DurationFormat, null)} ago). Is this OK?"); - if (!await Context.PromptYesNo(msg)) - { - throw Errors.SwitchMoveCancelled; - } + var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} This will move the latest switch ({lastSwitchMemberStr}) from {lastSwitchTimeStr} ({lastSwitchDeltaStr} ago) to {newSwitchTimeStr} ({newSwitchDeltaStr} ago). Is this OK?"); + if (!await Context.PromptYesNo(msg)) throw Errors.SwitchMoveCancelled; // aaaand *now* we do the move await Switches.MoveSwitch(lastTwoSwitches[0], time.ToInstant()); await Context.Channel.SendMessageAsync($"{Emojis.Success} Switch moved."); } + + [Command("delete")] + [MustHaveSystem] + public async Task SwitchDelete() + { + // Fetch the last two switches for the system to do bounds checking on + var lastTwoSwitches = (await Switches.GetSwitches(Context.SenderSystem, 2)).ToArray(); + if (lastTwoSwitches.Length == 0) throw Errors.NoRegisteredSwitches; + + var lastSwitchMembers = await Switches.GetSwitchMembers(lastTwoSwitches[0]); + var lastSwitchMemberStr = string.Join(", ", lastSwitchMembers.Select(m => m.Name)); + var lastSwitchDeltaStr = SystemClock.Instance.GetCurrentInstant().Minus(lastTwoSwitches[0].Timestamp).ToString(Formats.DurationFormat, null); + + IUserMessage msg; + if (lastTwoSwitches.Length == 1) + { + msg = await Context.Channel.SendMessageAsync( + $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). You have no other switches logged. Is this okay?"); + } + else + { + var secondSwitchMembers = await Switches.GetSwitchMembers(lastTwoSwitches[1]); + var secondSwitchMemberStr = string.Join(", ", secondSwitchMembers.Select(m => m.Name)); + var secondSwitchDeltaStr = SystemClock.Instance.GetCurrentInstant().Minus(lastTwoSwitches[1].Timestamp).ToString(Formats.DurationFormat, null); + msg = await Context.Channel.SendMessageAsync( + $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). The next latest switch is {secondSwitchMemberStr} ({secondSwitchDeltaStr} ago). Is this okay?"); + } + + if (!await Context.PromptYesNo(msg)) throw Errors.SwitchDeleteCancelled; + await Switches.DeleteSwitch(lastTwoSwitches[0]); + + await Context.Channel.SendMessageAsync($"{Emojis.Success} Switch deleted."); + } } } \ No newline at end of file diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 5ebf48c4..26d3f0f8 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -55,5 +55,6 @@ namespace PluralKit.Bot { public static PKError SwitchMoveBeforeSecondLast(ZonedDateTime time) => new PKError($"Can't move switch to before last switch time ({time.ToString(Formats.DateTimeFormat, null)}), as it would cause conflicts."); public static PKError SwitchMoveCancelled => new PKError("Switch move cancelled."); + public static PKError SwitchDeleteCancelled => new PKError("Switch deletion cancelled."); } } \ No newline at end of file diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index 3fea795c..2cd90caf 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -123,7 +123,7 @@ namespace PluralKit.Bot } /// Subclass of ICommandContext with PK-specific additional fields and functionality - public class PKCommandContext : SocketCommandContext, ICommandContext + public class PKCommandContext : SocketCommandContext { public IDbConnection Connection { get; } public PKSystem SenderSystem { get; } diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index 334667a1..98a787a2 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -203,5 +203,10 @@ namespace PluralKit { await _connection.ExecuteAsync("update switches set timestamp = @Time where id = @Id", new {Time = time, Id = sw.Id}); } + + public async Task DeleteSwitch(PKSwitch sw) + { + await _connection.ExecuteAsync("delete from switches where id = @Id", new {Id = sw.Id}); + } } } \ No newline at end of file From 6cfa4cb2e5f9b81dcb3d26ea1afb1756fd591ae8 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Thu, 13 Jun 2019 17:07:49 +0200 Subject: [PATCH 039/103] Add usage strings to switch commands --- PluralKit.Bot/Commands/SwitchCommands.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/PluralKit.Bot/Commands/SwitchCommands.cs b/PluralKit.Bot/Commands/SwitchCommands.cs index aaa55d8a..fcdb2126 100644 --- a/PluralKit.Bot/Commands/SwitchCommands.cs +++ b/PluralKit.Bot/Commands/SwitchCommands.cs @@ -21,6 +21,7 @@ namespace PluralKit.Bot.Commands public async Task Switch(params PKMember[] members) => await DoSwitchCommand(members); [Command("out")] + [Remarks("switch out")] [MustHaveSystem] public async Task SwitchOut() => await DoSwitchCommand(new PKMember[] { }); @@ -49,6 +50,7 @@ namespace PluralKit.Bot.Commands } [Command("move")] + [Remarks("switch move <date/time>")] [MustHaveSystem] public async Task SwitchMove([Remainder] string str) { @@ -92,6 +94,7 @@ namespace PluralKit.Bot.Commands } [Command("delete")] + [Remarks("switch delete")] [MustHaveSystem] public async Task SwitchDelete() { From 72cb838ad7bafcd061598e296dac30f175e07303 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Thu, 13 Jun 2019 20:33:17 +0200 Subject: [PATCH 040/103] Add system time zone command --- PluralKit.Bot/Commands/SystemCommands.cs | 31 ++++++++++++++++++++++++ PluralKit.Bot/Errors.cs | 4 +++ PluralKit.Core/Utils.cs | 2 +- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index 031ce9cf..bd36e766 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -1,9 +1,14 @@ using System; using System.Linq; using System.Runtime.Serialization; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Dapper; using Discord.Commands; +using NodaTime; +using NodaTime.Extensions; +using NodaTime.Text; +using NodaTime.TimeZones; namespace PluralKit.Bot.Commands { @@ -140,6 +145,32 @@ namespace PluralKit.Bot.Commands } } + [Command("timezone")] + [Remarks("system timezone [timezone]")] + public async Task SystemTimezone([Remainder] string zoneStr = null) + { + if (zoneStr == null) + { + Context.SenderSystem.UiTz = "UTC"; + await Systems.Save(Context.SenderSystem); + await Context.Channel.SendMessageAsync($"{Emojis.Success} System time zone cleared."); + return; + } + + var zones = DateTimeZoneProviders.Tzdb; + var zone = zones.GetZoneOrNull(zoneStr); + if (zone == null) throw Errors.InvalidTimeZone(zoneStr); + + var currentTime = SystemClock.Instance.GetCurrentInstant().InZone(zone); + var msg = await Context.Channel.SendMessageAsync( + $"This will change the system time zone to {zone.Id}. The current time is {currentTime.ToString(Formats.DateTimeFormat, null)}. Is this correct?"); + if (!await Context.PromptYesNo(msg)) throw Errors.TimezoneChangeCancelled; + Context.SenderSystem.UiTz = zone.Id; + await Systems.Save(Context.SenderSystem); + + await Context.Channel.SendMessageAsync($"System time zone changed to {zone.Id}."); + } + public override async Task<PKSystem> ReadContextParameterAsync(string value) { var res = await new PKSystemTypeReader().ReadAsync(Context, value, _services); diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 26d3f0f8..db51581b 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -56,5 +56,9 @@ namespace PluralKit.Bot { public static PKError SwitchMoveBeforeSecondLast(ZonedDateTime time) => new PKError($"Can't move switch to before last switch time ({time.ToString(Formats.DateTimeFormat, null)}), as it would cause conflicts."); public static PKError SwitchMoveCancelled => new PKError("Switch move cancelled."); public static PKError SwitchDeleteCancelled => new PKError("Switch deletion cancelled."); + public static PKError TimezoneParseError(string timezone) => new PKError($"Could not parse timezone offset {timezone}. Offset must be a value like 'UTC+5' or 'GMT-4:30'."); + + public static PKError InvalidTimeZone(string zoneStr) => new PKError($"Invalid time zone ID '{zoneStr}'. To find your time zone ID, use the following website: <https://xske.github.io/tz>"); + public static PKError TimezoneChangeCancelled => new PKError("Time zone change cancelled."); } } \ No newline at end of file diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index d9539bd4..e94a4951 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -206,7 +206,7 @@ namespace PluralKit public static class Formats { - public static string DateTimeFormat = "yyyy-MM-dd HH-mm-ss"; + public static string DateTimeFormat = "yyyy-MM-dd HH:mm:ss"; public static string DurationFormat = "D'd' h'h' m'm' s's'"; } } \ No newline at end of file From cd9a3e0abd08f0f205eb0fadcd27e39643a203a4 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Thu, 13 Jun 2019 23:42:39 +0200 Subject: [PATCH 041/103] Expand system time selection logic --- PluralKit.Bot/Commands/SystemCommands.cs | 61 ++++++++++++++++- PluralKit.Bot/ContextUtils.cs | 84 ++++++++++++++++++++++++ PluralKit.Bot/Errors.cs | 2 + PluralKit.Bot/Utils.cs | 2 + PluralKit.Core/Utils.cs | 17 +++++ 5 files changed, 164 insertions(+), 2 deletions(-) diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index bd36e766..491879d4 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -157,8 +157,7 @@ namespace PluralKit.Bot.Commands return; } - var zones = DateTimeZoneProviders.Tzdb; - var zone = zones.GetZoneOrNull(zoneStr); + var zone = await FindTimeZone(zoneStr); if (zone == null) throw Errors.InvalidTimeZone(zoneStr); var currentTime = SystemClock.Instance.GetCurrentInstant().InZone(zone); @@ -171,6 +170,64 @@ namespace PluralKit.Bot.Commands await Context.Channel.SendMessageAsync($"System time zone changed to {zone.Id}."); } + public async Task<DateTimeZone> FindTimeZone(string zoneStr) { + // First, if we're given a flag emoji, we extract the flag emoji code from it. + zoneStr = PluralKit.Utils.ExtractCountryFlag(zoneStr) ?? zoneStr; + + // Then, we find all *locations* matching either the given country code or the country name. + var locations = TzdbDateTimeZoneSource.Default.Zone1970Locations; + var matchingLocations = locations.Where(l => l.Countries.Any(c => + string.Equals(c.Code, zoneStr, StringComparison.InvariantCultureIgnoreCase) || + string.Equals(c.Name, zoneStr, StringComparison.InvariantCultureIgnoreCase))); + + // Then, we find all (unique) time zone IDs that match. + var matchingZones = matchingLocations.Select(l => DateTimeZoneProviders.Tzdb.GetZoneOrNull(l.ZoneId)) + .Distinct().ToList(); + + // If the set of matching zones is empty (ie. we didn't find anything), we try a few other things. + if (matchingZones.Count == 0) + { + // First, we try to just find the time zone given directly and return that. + var givenZone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(zoneStr); + if (givenZone != null) return givenZone; + + // If we didn't find anything there either, we try parsing the string as an offset, then + // find all possible zones that match that offset. For an offset like UTC+2, this doesn't *quite* + // work, since there are 57(!) matching zones (as of 2019-06-13) - but for less populated time zones + // this could work nicely. + var inputWithoutUtc = zoneStr.Replace("UTC", "").Replace("GMT", ""); + + var res = OffsetPattern.CreateWithInvariantCulture("+H").Parse(inputWithoutUtc); + if (!res.Success) res = OffsetPattern.CreateWithInvariantCulture("+H:mm").Parse(inputWithoutUtc); + + // If *this* didn't parse correctly, fuck it, bail. + if (!res.Success) return null; + var offset = res.Value; + + // To try to reduce the count, we go by locations from the 1970+ database instead of just the full database + // This elides regions that have been identical since 1970, omitting small distinctions due to Ancient History(tm). + var allZones = TzdbDateTimeZoneSource.Default.Zone1970Locations.Select(l => l.ZoneId).Distinct(); + matchingZones = allZones.Select(z => DateTimeZoneProviders.Tzdb.GetZoneOrNull(z)) + .Where(z => z.GetUtcOffset(SystemClock.Instance.GetCurrentInstant()) == offset).ToList(); + } + + // If we have a list of viable time zones, we ask the user which is correct. + + // If we only have one, return that one. + if (matchingZones.Count == 1) + return matchingZones.First(); + + // Otherwise, prompt and return! + return await Context.Choose("There were multiple matches for your time zone query. Please select the region that matches you the closest:", matchingZones, + z => + { + if (TzdbDateTimeZoneSource.Default.Aliases.Contains(z.Id)) + return $"**{z.Id}**, {string.Join(", ", TzdbDateTimeZoneSource.Default.Aliases[z.Id])}"; + + return $"**{z.Id}**"; + }); + } + public override async Task<PKSystem> ReadContextParameterAsync(string value) { var res = await new PKSystemTypeReader().ReadAsync(Context, value, _services); diff --git a/PluralKit.Bot/ContextUtils.cs b/PluralKit.Bot/ContextUtils.cs index 9d9f448e..1007ddc8 100644 --- a/PluralKit.Bot/ContextUtils.cs +++ b/PluralKit.Bot/ContextUtils.cs @@ -56,6 +56,8 @@ namespace PluralKit.Bot { } public static async Task Paginate<T>(this ICommandContext ctx, ICollection<T> items, int itemsPerPage, string title, Action<EmbedBuilder, IEnumerable<T>> renderer) { + // TODO: make this generic enough we can use it in Choose<T> below + var pageCount = (items.Count / itemsPerPage) + 1; Embed MakeEmbedForPage(int page) { var eb = new EmbedBuilder(); @@ -93,6 +95,88 @@ namespace PluralKit.Bot { if (await ctx.HasPermission(ChannelPermission.ManageMessages)) await msg.RemoveAllReactionsAsync(); else await msg.RemoveReactionsAsync(ctx.Client.CurrentUser, botEmojis); } + + public static async Task<T> Choose<T>(this ICommandContext ctx, string description, IList<T> items, Func<T, string> display = null) + { + // Generate a list of :regional_indicator_?: emoji surrogate pairs (starting at codepoint 0x1F1E6) + // We just do 7 (ABCDEFG), this amount is arbitrary (although sending a lot of emojis takes a while) + var pageSize = 7; + var indicators = new string[pageSize]; + for (var i = 0; i < pageSize; i++) indicators[i] = char.ConvertFromUtf32(0x1F1E6 + i); + + // Default to x.ToString() + if (display == null) display = x => x.ToString(); + + string MakeOptionList(int page) + { + var makeOptionList = string.Join("\n", items + .Skip(page * pageSize) + .Take(pageSize) + .Select((x, i) => $"{indicators[i]} {display(x)}")); + return makeOptionList; + } + + // If we have more items than the page size, we paginate as appropriate + if (items.Count > pageSize) + { + var currPage = 0; + var pageCount = (items.Count-1) / pageSize + 1; + + // Send the original message + var msg = await ctx.Channel.SendMessageAsync($"**[Page {currPage + 1}/{pageCount}]**\n{description}\n{MakeOptionList(currPage)}"); + + // Add back/forward reactions and the actual indicator emojis + async Task AddEmojis() + { + await msg.AddReactionAsync(new Emoji("\u2B05")); + await msg.AddReactionAsync(new Emoji("\u27A1")); + for (int i = 0; i < items.Count; i++) await msg.AddReactionAsync(new Emoji(indicators[i])); + } + + AddEmojis(); // Not concerned about awaiting + + + while (true) + { + // Wait for a reaction + var reaction = await ctx.AwaitReaction(msg, ctx.User); + + // If it's a movement reaction, inc/dec the page index + if (reaction.Emote.Name == "\u2B05") currPage -= 1; // < + if (reaction.Emote.Name == "\u27A1") currPage += 1; // > + if (currPage < 0) currPage += pageCount; + if (currPage >= pageCount) currPage -= pageCount; + + // If it's an indicator emoji, return the relevant item + if (indicators.Contains(reaction.Emote.Name)) + { + var idx = Array.IndexOf(indicators, reaction.Emote.Name) + pageSize * currPage; + // only if it's in bounds, though + // eg. 8 items, we're on page 2, and I hit D (3 + 1*7 = index 10 on an 8-long list) = boom + if (idx < items.Count) return items[idx]; + } + + msg.RemoveReactionAsync(reaction.Emote, ctx.User); // don't care about awaiting + await msg.ModifyAsync(mp => mp.Content = $"**[Page {currPage + 1}/{pageCount}]**\n{description}\n{MakeOptionList(currPage)}"); + } + } + else + { + var msg = await ctx.Channel.SendMessageAsync($"{description}\n{MakeOptionList(0)}"); + + // Add the relevant reactions (we don't care too much about awaiting) + async Task AddEmojis() + { + for (int i = 0; i < items.Count; i++) await msg.AddReactionAsync(new Emoji(indicators[i])); + } + + AddEmojis(); + + // Then wait for a reaction and return whichever one we found + var reaction = await ctx.AwaitReaction(msg, ctx.User,rx => indicators.Contains(rx.Emote.Name)); + return items[Array.IndexOf(indicators, reaction.Emote.Name)]; + } + } public static async Task<ChannelPermissions> Permissions(this ICommandContext ctx) { if (ctx.Channel is IGuildChannel) { diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index db51581b..5a37569b 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -60,5 +60,7 @@ namespace PluralKit.Bot { public static PKError InvalidTimeZone(string zoneStr) => new PKError($"Invalid time zone ID '{zoneStr}'. To find your time zone ID, use the following website: <https://xske.github.io/tz>"); public static PKError TimezoneChangeCancelled => new PKError("Time zone change cancelled."); + + public static PKError AmbiguousTimeZone(string zoneStr, int count) => new PKError($"The time zone query '{zoneStr}' resulted in **{count}** different time zone regions. Try being more specific - e.g. pass an exact time zone specifier from the following website: <https://xske.github.io/tz>"); } } \ No newline at end of file diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index 2cd90caf..726737b6 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Data; using System.Linq; using System.Net.Http; @@ -9,6 +10,7 @@ using Discord.Commands; using Discord.Commands.Builders; using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; +using NodaTime; using Image = SixLabors.ImageSharp.Image; namespace PluralKit.Bot diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index e94a4951..72867783 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -194,6 +194,23 @@ namespace PluralKit // Still haven't parsed something, we just give up lmao return null; } + + public static string ExtractCountryFlag(string flag) + { + if (flag.Length != 4) return null; + try + { + var cp1 = char.ConvertToUtf32(flag, 0); + var cp2 = char.ConvertToUtf32(flag, 2); + if (cp1 < 0x1F1E6 || cp1 > 0x1F1FF) return null; + if (cp2 < 0x1F1E6 || cp2 > 0x1F1FF) return null; + return $"{(char) (cp1 - 0x1F1E6 + 'A')}{(char) (cp2 - 0x1F1E6 + 'A')}"; + } + catch (ArgumentException) + { + return null; + } + } } public static class Emojis { From 652afffb8c7a1b8b53fccc8dc38118828b85ef6a Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Fri, 14 Jun 2019 22:48:19 +0200 Subject: [PATCH 042/103] Add importing and exporting function --- PluralKit.Bot/Bot.cs | 2 + .../Commands/ImportExportCommands.cs | 85 +++++++ PluralKit.Bot/Errors.cs | 3 +- PluralKit.Core/DataFiles.cs | 223 ++++++++++++++++++ PluralKit.Core/PluralKit.Core.csproj | 6 + PluralKit.Core/Stores.cs | 2 +- PluralKit.Core/Utils.cs | 2 + 7 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 PluralKit.Bot/Commands/ImportExportCommands.cs create mode 100644 PluralKit.Core/DataFiles.cs diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index a8dc540a..73392101 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -68,6 +68,8 @@ namespace PluralKit.Bot .AddTransient<EmbedService>() .AddTransient<ProxyService>() .AddTransient<LogChannelService>() + .AddTransient<DataFileService>() + .AddSingleton<WebhookCacheService>() .AddTransient<SystemStore>() diff --git a/PluralKit.Bot/Commands/ImportExportCommands.cs b/PluralKit.Bot/Commands/ImportExportCommands.cs new file mode 100644 index 00000000..4b54b060 --- /dev/null +++ b/PluralKit.Bot/Commands/ImportExportCommands.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Discord.Commands; +using Newtonsoft.Json; + +namespace PluralKit.Bot.Commands +{ + public class ImportExportCommands : ModuleBase<PKCommandContext> + { + public DataFileService DataFiles { get; set; } + + [Command("import")] + [Remarks("import [fileurl]")] + public async Task Import([Remainder] string url = null) + { + if (url == null) url = Context.Message.Attachments.FirstOrDefault()?.Filename; + if (url == null) throw Errors.NoImportFilePassed; + + await Context.BusyIndicator(async () => + { + using (var client = new HttpClient()) + { + var response = await client.GetAsync(url); + if (!response.IsSuccessStatusCode) throw Errors.InvalidImportFile; + var str = await response.Content.ReadAsStringAsync(); + + var data = TryDeserialize(str); + if (!data.HasValue || !data.Value.Valid) throw Errors.InvalidImportFile; + + if (Context.SenderSystem != null && Context.SenderSystem.Hid != data.Value.Id) + { + // TODO: prompt "are you sure you want to import someone else's system? + } + + // If passed system is null, it'll create a new one + // (and that's okay!) + var result = await DataFiles.ImportSystem(data.Value, Context.SenderSystem); + + if (Context.SenderSystem == null) + { + await Context.Channel.SendMessageAsync($"{Emojis.Success} PluralKit has created a system for you based on the given file. Your system ID is `{result.System.Hid}`. Type `pk;system` for more information."); + } + else + { + await Context.Channel.SendMessageAsync($"{Emojis.Success} Updated {result.ModifiedNames.Count} members, created {result.AddedNames.Count} members. Type `pk;system list` to check!"); + } + } + }); + } + + [Command("export")] + [Remarks("export")] + [MustHaveSystem] + public async Task Export() + { + await Context.BusyIndicator(async () => + { + var data = await DataFiles.ExportSystem(Context.SenderSystem); + var json = JsonConvert.SerializeObject(data, Formatting.None); + + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + await Context.Channel.SendFileAsync(stream, "system.json", $"{Emojis.Success} Here you go!"); + }); + } + + private DataFileSystem? TryDeserialize(string json) + { + try + { + return JsonConvert.DeserializeObject<DataFileSystem>(json); + } + catch (JsonException e) + { + Console.WriteLine("uww"); + } + + return null; + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 5a37569b..cce4dff3 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -60,7 +60,8 @@ namespace PluralKit.Bot { public static PKError InvalidTimeZone(string zoneStr) => new PKError($"Invalid time zone ID '{zoneStr}'. To find your time zone ID, use the following website: <https://xske.github.io/tz>"); public static PKError TimezoneChangeCancelled => new PKError("Time zone change cancelled."); - public static PKError AmbiguousTimeZone(string zoneStr, int count) => new PKError($"The time zone query '{zoneStr}' resulted in **{count}** different time zone regions. Try being more specific - e.g. pass an exact time zone specifier from the following website: <https://xske.github.io/tz>"); + public static Exception NoImportFilePassed => new PKError("You must either pass an URL to a file as a command parameter, or as an attachment to the message containing the command."); + public static Exception InvalidImportFile => new PKError("Imported data file invalid. Make sure this is a .json file directly exported from PluralKit or Tupperbox."); } } \ No newline at end of file diff --git a/PluralKit.Core/DataFiles.cs b/PluralKit.Core/DataFiles.cs new file mode 100644 index 00000000..52ecb91f --- /dev/null +++ b/PluralKit.Core/DataFiles.cs @@ -0,0 +1,223 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using NodaTime; +using NodaTime.Text; +using NodaTime.TimeZones; + +namespace PluralKit.Bot +{ + public class DataFileService + { + private SystemStore _systems; + private MemberStore _members; + private SwitchStore _switches; + + public DataFileService(SystemStore systems, MemberStore members, SwitchStore switches) + { + _systems = systems; + _members = members; + _switches = switches; + } + + public async Task<DataFileSystem> ExportSystem(PKSystem system) + { + var members = new List<DataFileMember>(); + foreach (var member in await _members.GetBySystem(system)) members.Add(await ExportMember(member)); + + var switches = new List<DataFileSwitch>(); + foreach (var sw in await _switches.GetSwitches(system, 999999)) switches.Add(await ExportSwitch(sw)); + + return new DataFileSystem + { + Id = system.Hid, + Name = system.Name, + Description = system.Description, + Tag = system.Tag, + AvatarUrl = system.AvatarUrl, + TimeZone = system.UiTz, + Members = members, + Switches = switches, + Created = system.Created.ToString(Formats.TimestampExportFormat, null), + LinkedAccounts = (await _systems.GetLinkedAccountIds(system)).ToList() + }; + } + + private async Task<DataFileMember> ExportMember(PKMember member) => new DataFileMember + { + Id = member.Hid, + Name = member.Name, + Description = member.Description, + Birthdate = member.Birthday?.ToString(Formats.DateExportFormat, null), + Pronouns = member.Pronouns, + Color = member.Color, + AvatarUrl = member.AvatarUrl, + Prefix = member.Prefix, + Suffix = member.Suffix, + Created = member.Created.ToString(Formats.TimestampExportFormat, null), + MessageCount = await _members.MessageCount(member) + }; + + private async Task<DataFileSwitch> ExportSwitch(PKSwitch sw) => new DataFileSwitch + { + Members = (await _switches.GetSwitchMembers(sw)).Select(m => m.Hid).ToList(), + Timestamp = sw.Timestamp.ToString(Formats.TimestampExportFormat, null) + }; + + public async Task<ImportResult> ImportSystem(DataFileSystem data, PKSystem system) + { + var result = new ImportResult { AddedNames = new List<string>(), ModifiedNames = new List<string>() }; + + // If we don't already have a system to save to, create one + if (system == null) system = await _systems.Create(data.Name); + + // Apply system info + system.Name = data.Name; + system.Description = data.Description; + system.Tag = data.Tag; + system.AvatarUrl = data.AvatarUrl; + system.UiTz = data.TimeZone ?? "UTC"; + await _systems.Save(system); + + // Apply members + // TODO: parallelize? + foreach (var dataMember in data.Members) + { + // If member's given an ID, we try to look up the member with the given ID + PKMember member = null; + if (dataMember.Id != null) + { + member = await _members.GetByHid(dataMember.Id); + + // ...but if it's a different system's member, we just make a new one anyway + if (member != null && member.System != system.Id) member = null; + } + + // Try to look up by name, too + if (member == null) member = await _members.GetByName(system, dataMember.Name); + + // And if all else fails (eg. fresh import from Tupperbox, etc) we just make a member lol + if (member == null) + { + member = await _members.Create(system, dataMember.Name); + result.AddedNames.Add(dataMember.Name); + } + else + { + result.ModifiedNames.Add(dataMember.Name); + } + + // Apply member info + member.Name = dataMember.Name; + member.Description = dataMember.Description; + member.Color = dataMember.Color; + member.AvatarUrl = dataMember.AvatarUrl; + member.Prefix = dataMember.Prefix; + member.Suffix = dataMember.Suffix; + + var birthdayParse = LocalDatePattern.CreateWithInvariantCulture(Formats.DateExportFormat).Parse(dataMember.Birthdate); + member.Birthday = birthdayParse.Success ? (LocalDate?) birthdayParse.Value : null; + await _members.Save(member); + } + + // TODO: import switches, too? + + result.System = system; + return result; + } + } + + public struct ImportResult + { + public ICollection<string> AddedNames; + public ICollection<string> ModifiedNames; + public PKSystem System; + } + + public struct DataFileSystem + { + [JsonProperty("id")] + public string Id; + + [JsonProperty("name")] + public string Name; + + [JsonProperty("description")] + public string Description; + + [JsonProperty("tag")] + public string Tag; + + [JsonProperty("avatar_url")] + public string AvatarUrl; + + [JsonProperty("timezone")] + public string TimeZone; + + [JsonProperty("members")] + public ICollection<DataFileMember> Members; + + [JsonProperty("switches")] + public ICollection<DataFileSwitch> Switches; + + [JsonProperty("accounts")] + public ICollection<ulong> LinkedAccounts; + + [JsonProperty("created")] + public string Created; + + private bool TimeZoneValid => TimeZone == null || DateTimeZoneProviders.Tzdb.GetZoneOrNull(TimeZone) != null; + + [JsonIgnore] + public bool Valid => TimeZoneValid && Members.All(m => m.Valid); + } + + public struct DataFileMember + { + [JsonProperty("id")] + public string Id; + + [JsonProperty("name")] + public string Name; + + [JsonProperty("description")] + public string Description; + + [JsonProperty("birthday")] + public string Birthdate; + + [JsonProperty("pronouns")] + public string Pronouns; + + [JsonProperty("color")] + public string Color; + + [JsonProperty("avatar_url")] + public string AvatarUrl; + + [JsonProperty("prefix")] + public string Prefix; + + [JsonProperty("suffix")] + public string Suffix; + + [JsonProperty("message_count")] + public int MessageCount; + + [JsonProperty("created")] + public string Created; + + [JsonIgnore] + public bool Valid => Name != null; + } + + public struct DataFileSwitch + { + [JsonProperty("timestamp")] + public string Timestamp; + + [JsonProperty("members")] + public ICollection<string> Members; + } +} \ No newline at end of file diff --git a/PluralKit.Core/PluralKit.Core.csproj b/PluralKit.Core/PluralKit.Core.csproj index 817f3249..ce714be0 100644 --- a/PluralKit.Core/PluralKit.Core.csproj +++ b/PluralKit.Core/PluralKit.Core.csproj @@ -15,4 +15,10 @@ <PackageReference Include="Npgsql.NodaTime" Version="4.0.6" /> </ItemGroup> + <ItemGroup> + <Reference Include="Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed"> + <HintPath>..\..\..\.nuget\packages\newtonsoft.json\11.0.2\lib\netstandard2.0\Newtonsoft.Json.dll</HintPath> + </Reference> + </ItemGroup> + </Project> diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index 98a787a2..14cb6483 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -84,7 +84,7 @@ namespace PluralKit { public async Task<PKMember> GetByName(PKSystem system, string name) { // QueryFirst, since members can (in rare cases) share names - return await conn.QueryFirstOrDefaultAsync<PKMember>("select * from members where lower(name) = @Name and system = @SystemID", new { Name = name, SystemID = system.Id }); + return await conn.QueryFirstOrDefaultAsync<PKMember>("select * from members where lower(name) = lower(@Name) and system = @SystemID", new { Name = name, SystemID = system.Id }); } public async Task<ICollection<PKMember>> GetUnproxyableMembers(PKSystem system) { diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index 72867783..35c75b0d 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -224,6 +224,8 @@ namespace PluralKit public static class Formats { public static string DateTimeFormat = "yyyy-MM-dd HH:mm:ss"; + public static string DateExportFormat = "yyyy-MM-dd"; + public static string TimestampExportFormat = "g"; public static string DurationFormat = "D'd' h'h' m'm' s's'"; } } \ No newline at end of file From 9be7514fb96b9c1c056d36083fb2596d60e6c75f Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sat, 15 Jun 2019 11:02:46 +0200 Subject: [PATCH 043/103] Fix Newtonsoft.Json NuGet dependency --- PluralKit.Core/PluralKit.Core.csproj | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/PluralKit.Core/PluralKit.Core.csproj b/PluralKit.Core/PluralKit.Core.csproj index ce714be0..d772231a 100644 --- a/PluralKit.Core/PluralKit.Core.csproj +++ b/PluralKit.Core/PluralKit.Core.csproj @@ -11,14 +11,9 @@ <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="2.2.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="2.2.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="2.2.4" /> + <PackageReference Include="Newtonsoft.Json" Version="12.0.2" /> <PackageReference Include="Npgsql" Version="4.0.6" /> <PackageReference Include="Npgsql.NodaTime" Version="4.0.6" /> </ItemGroup> - <ItemGroup> - <Reference Include="Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed"> - <HintPath>..\..\..\.nuget\packages\newtonsoft.json\11.0.2\lib\netstandard2.0\Newtonsoft.Json.dll</HintPath> - </Reference> - </ItemGroup> - </Project> From 1e1ef4495f7044db460ce714410c953b4671e3f0 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sat, 15 Jun 2019 11:55:11 +0200 Subject: [PATCH 044/103] Add Tupperbox importing support --- .../Commands/ImportExportCommands.cs | 64 ++++- PluralKit.Bot/Errors.cs | 5 +- PluralKit.Core/DataFiles.cs | 232 +++++++++++------- 3 files changed, 198 insertions(+), 103 deletions(-) diff --git a/PluralKit.Bot/Commands/ImportExportCommands.cs b/PluralKit.Bot/Commands/ImportExportCommands.cs index 4b54b060..5e10e1e2 100644 --- a/PluralKit.Bot/Commands/ImportExportCommands.cs +++ b/PluralKit.Bot/Commands/ImportExportCommands.cs @@ -1,7 +1,6 @@ using System; using System.IO; using System.Linq; -using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; @@ -27,19 +26,66 @@ namespace PluralKit.Bot.Commands { var response = await client.GetAsync(url); if (!response.IsSuccessStatusCode) throw Errors.InvalidImportFile; - var str = await response.Content.ReadAsStringAsync(); + var json = await response.Content.ReadAsStringAsync(); - var data = TryDeserialize(str); - if (!data.HasValue || !data.Value.Valid) throw Errors.InvalidImportFile; - - if (Context.SenderSystem != null && Context.SenderSystem.Hid != data.Value.Id) + var settings = new JsonSerializerSettings { - // TODO: prompt "are you sure you want to import someone else's system? + MissingMemberHandling = MissingMemberHandling.Error + }; + + + DataFileSystem data; + + // TODO: can we clean up this mess? + try + { + data = JsonConvert.DeserializeObject<DataFileSystem>(json, settings); + } + catch (JsonException) + { + try + { + var tupperbox = JsonConvert.DeserializeObject<TupperboxProfile>(json, settings); + if (!tupperbox.Valid) throw Errors.InvalidImportFile; + + var res = tupperbox.ToPluralKit(); + if (res.HadGroups || res.HadMultibrackets || res.HadIndividualTags) + { + var issueStr = + $"{Emojis.Warn} The following potential issues were detected converting your Tupperbox input file:"; + if (res.HadGroups) + issueStr += + "\n- PluralKit does not support member groups. Members will be imported without groups."; + if (res.HadMultibrackets) + issueStr += "\n- PluralKit does not support members with multiple proxy tags. Only the first pair will be imported."; + if (res.HadIndividualTags) + issueStr += + "\n- PluralKit does not support per-member system tags. Since you had multiple members with distinct tags, tags will not be imported. You can set your system tag using the `pk;system tag <tag>` command later."; + + var msg = await Context.Channel.SendMessageAsync($"{issueStr}\n\nDo you want to proceed with the import?"); + if (!await Context.PromptYesNo(msg)) throw Errors.ImportCancelled; + } + + data = res.System; + } + catch (JsonException) + { + throw Errors.InvalidImportFile; + } + } + + + if (!data.Valid) throw Errors.InvalidImportFile; + + if (data.LinkedAccounts != null && !data.LinkedAccounts.Contains(Context.User.Id)) + { + var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} You seem to importing a system profile belonging to another account. Are you sure you want to proceed?"); + if (!await Context.PromptYesNo(msg)) throw Errors.ImportCancelled; } // If passed system is null, it'll create a new one // (and that's okay!) - var result = await DataFiles.ImportSystem(data.Value, Context.SenderSystem); + var result = await DataFiles.ImportSystem(data, Context.SenderSystem); if (Context.SenderSystem == null) { @@ -72,7 +118,7 @@ namespace PluralKit.Bot.Commands { try { - return JsonConvert.DeserializeObject<DataFileSystem>(json); + } catch (JsonException e) { diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index cce4dff3..edff5176 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -61,7 +61,8 @@ namespace PluralKit.Bot { public static PKError InvalidTimeZone(string zoneStr) => new PKError($"Invalid time zone ID '{zoneStr}'. To find your time zone ID, use the following website: <https://xske.github.io/tz>"); public static PKError TimezoneChangeCancelled => new PKError("Time zone change cancelled."); public static PKError AmbiguousTimeZone(string zoneStr, int count) => new PKError($"The time zone query '{zoneStr}' resulted in **{count}** different time zone regions. Try being more specific - e.g. pass an exact time zone specifier from the following website: <https://xske.github.io/tz>"); - public static Exception NoImportFilePassed => new PKError("You must either pass an URL to a file as a command parameter, or as an attachment to the message containing the command."); - public static Exception InvalidImportFile => new PKError("Imported data file invalid. Make sure this is a .json file directly exported from PluralKit or Tupperbox."); + public static PKError NoImportFilePassed => new PKError("You must either pass an URL to a file as a command parameter, or as an attachment to the message containing the command."); + public static PKError InvalidImportFile => new PKError("Imported data file invalid. Make sure this is a .json file directly exported from PluralKit or Tupperbox."); + public static PKError ImportCancelled => new PKError("Import cancelled."); } } \ No newline at end of file diff --git a/PluralKit.Core/DataFiles.cs b/PluralKit.Core/DataFiles.cs index 52ecb91f..5bcdaf3a 100644 --- a/PluralKit.Core/DataFiles.cs +++ b/PluralKit.Core/DataFiles.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using Newtonsoft.Json; using NodaTime; using NodaTime.Text; -using NodaTime.TimeZones; namespace PluralKit.Bot { @@ -49,7 +48,7 @@ namespace PluralKit.Bot Id = member.Hid, Name = member.Name, Description = member.Description, - Birthdate = member.Birthday?.ToString(Formats.DateExportFormat, null), + Birthday = member.Birthday?.ToString(Formats.DateExportFormat, null), Pronouns = member.Pronouns, Color = member.Color, AvatarUrl = member.AvatarUrl, @@ -67,19 +66,19 @@ namespace PluralKit.Bot public async Task<ImportResult> ImportSystem(DataFileSystem data, PKSystem system) { - var result = new ImportResult { AddedNames = new List<string>(), ModifiedNames = new List<string>() }; - + var result = new ImportResult {AddedNames = new List<string>(), ModifiedNames = new List<string>()}; + // If we don't already have a system to save to, create one if (system == null) system = await _systems.Create(data.Name); - + // Apply system info system.Name = data.Name; - system.Description = data.Description; - system.Tag = data.Tag; - system.AvatarUrl = data.AvatarUrl; - system.UiTz = data.TimeZone ?? "UTC"; + if (data.Description != null) system.Description = data.Description; + if (data.Tag != null) system.Tag = data.Tag; + if (data.AvatarUrl != null) system.AvatarUrl = data.AvatarUrl; + if (data.TimeZone != null) system.UiTz = data.TimeZone ?? "UTC"; await _systems.Save(system); - + // Apply members // TODO: parallelize? foreach (var dataMember in data.Members) @@ -93,10 +92,10 @@ namespace PluralKit.Bot // ...but if it's a different system's member, we just make a new one anyway if (member != null && member.System != system.Id) member = null; } - + // Try to look up by name, too if (member == null) member = await _members.GetByName(system, dataMember.Name); - + // And if all else fails (eg. fresh import from Tupperbox, etc) we just make a member lol if (member == null) { @@ -107,20 +106,28 @@ namespace PluralKit.Bot { result.ModifiedNames.Add(dataMember.Name); } - + // Apply member info member.Name = dataMember.Name; - member.Description = dataMember.Description; - member.Color = dataMember.Color; - member.AvatarUrl = dataMember.AvatarUrl; - member.Prefix = dataMember.Prefix; - member.Suffix = dataMember.Suffix; + if (dataMember.Description != null) member.Description = dataMember.Description; + if (dataMember.Color != null) member.Color = dataMember.Color; + if (dataMember.AvatarUrl != null) member.AvatarUrl = dataMember.AvatarUrl; + if (dataMember.Prefix != null || dataMember.Suffix != null) + { + member.Prefix = dataMember.Prefix; + member.Suffix = dataMember.Suffix; + } + + if (dataMember.Birthday != null) + { + var birthdayParse = LocalDatePattern.CreateWithInvariantCulture(Formats.DateExportFormat) + .Parse(dataMember.Birthday); + member.Birthday = birthdayParse.Success ? (LocalDate?) birthdayParse.Value : null; + } - var birthdayParse = LocalDatePattern.CreateWithInvariantCulture(Formats.DateExportFormat).Parse(dataMember.Birthdate); - member.Birthday = birthdayParse.Success ? (LocalDate?) birthdayParse.Value : null; await _members.Save(member); } - + // TODO: import switches, too? result.System = system; @@ -137,87 +144,128 @@ namespace PluralKit.Bot public struct DataFileSystem { - [JsonProperty("id")] - public string Id; - - [JsonProperty("name")] - public string Name; - - [JsonProperty("description")] - public string Description; - - [JsonProperty("tag")] - public string Tag; - - [JsonProperty("avatar_url")] - public string AvatarUrl; - - [JsonProperty("timezone")] - public string TimeZone; + [JsonProperty("id")] public string Id; + [JsonProperty("name")] public string Name; + [JsonProperty("description")] public string Description; + [JsonProperty("tag")] public string Tag; + [JsonProperty("avatar_url")] public string AvatarUrl; + [JsonProperty("timezone")] public string TimeZone; + [JsonProperty("members")] public ICollection<DataFileMember> Members; + [JsonProperty("switches")] public ICollection<DataFileSwitch> Switches; + [JsonProperty("accounts")] public ICollection<ulong> LinkedAccounts; + [JsonProperty("created")] public string Created; - [JsonProperty("members")] - public ICollection<DataFileMember> Members; - - [JsonProperty("switches")] - public ICollection<DataFileSwitch> Switches; - - [JsonProperty("accounts")] - public ICollection<ulong> LinkedAccounts; - - [JsonProperty("created")] - public string Created; - private bool TimeZoneValid => TimeZone == null || DateTimeZoneProviders.Tzdb.GetZoneOrNull(TimeZone) != null; - - [JsonIgnore] - public bool Valid => TimeZoneValid && Members.All(m => m.Valid); + + [JsonIgnore] public bool Valid => TimeZoneValid && Members != null && Members.All(m => m.Valid); } public struct DataFileMember { - [JsonProperty("id")] - public string Id; - - [JsonProperty("name")] - public string Name; - - [JsonProperty("description")] - public string Description; - - [JsonProperty("birthday")] - public string Birthdate; - - [JsonProperty("pronouns")] - public string Pronouns; - - [JsonProperty("color")] - public string Color; - - [JsonProperty("avatar_url")] - public string AvatarUrl; - - [JsonProperty("prefix")] - public string Prefix; - - [JsonProperty("suffix")] - public string Suffix; - - [JsonProperty("message_count")] - public int MessageCount; + [JsonProperty("id")] public string Id; + [JsonProperty("name")] public string Name; + [JsonProperty("description")] public string Description; + [JsonProperty("birthday")] public string Birthday; + [JsonProperty("pronouns")] public string Pronouns; + [JsonProperty("color")] public string Color; + [JsonProperty("avatar_url")] public string AvatarUrl; + [JsonProperty("prefix")] public string Prefix; + [JsonProperty("suffix")] public string Suffix; + [JsonProperty("message_count")] public int MessageCount; + [JsonProperty("created")] public string Created; - [JsonProperty("created")] - public string Created; - - [JsonIgnore] - public bool Valid => Name != null; + [JsonIgnore] public bool Valid => Name != null; } public struct DataFileSwitch { - [JsonProperty("timestamp")] - public string Timestamp; - - [JsonProperty("members")] - public ICollection<string> Members; + [JsonProperty("timestamp")] public string Timestamp; + [JsonProperty("members")] public ICollection<string> Members; + } + + public struct TupperboxConversionResult + { + public bool HadGroups; + public bool HadIndividualTags; + public bool HadMultibrackets; + public DataFileSystem System; + } + + public struct TupperboxProfile + { + [JsonProperty("tuppers")] public ICollection<TupperboxTupper> Tuppers; + [JsonProperty("groups")] public ICollection<TupperboxGroup> Groups; + + [JsonIgnore] public bool Valid => Tuppers != null && Groups != null && Tuppers.All(t => t.Valid) && Groups.All(g => g.Valid); + + public TupperboxConversionResult ToPluralKit() + { + // Set by member conversion function + string lastSetTag = null; + + TupperboxConversionResult output = default(TupperboxConversionResult); + + output.System = new DataFileSystem + { + Members = Tuppers.Select(t => t.ToPluralKit(ref lastSetTag, ref output.HadMultibrackets, + ref output.HadGroups, ref output.HadMultibrackets)).ToList(), + + // If we haven't had multiple tags set, use the last (and only) one we set as the system tag + Tag = !output.HadIndividualTags ? lastSetTag : null + }; + return output; + } + } + + public struct TupperboxTupper + { + [JsonProperty("name")] public string Name; + [JsonProperty("avatar_url")] public string AvatarUrl; + [JsonProperty("brackets")] public ICollection<string> Brackets; + [JsonProperty("posts")] public int Posts; // Not supported by PK + [JsonProperty("show_brackets")] public bool ShowBrackets; // Not supported by PK + [JsonProperty("birthday")] public string Birthday; + [JsonProperty("description")] public string Description; + [JsonProperty("tag")] public string Tag; // Not supported by PK + [JsonProperty("group_id")] public string GroupId; // Not supported by PK + [JsonProperty("group_pos")] public int? GroupPos; // Not supported by PK + + [JsonIgnore] public bool Valid => Name != null && Brackets != null && Brackets.Count % 2 == 0; + + public DataFileMember ToPluralKit(ref string lastSetTag, ref bool multipleTags, ref bool hasGroup, ref bool hasMultiBrackets) + { + // If we've set a tag before and it's not the same as this one, + // then we have multiple unique tags and we pass that flag back to the caller + if (Tag != null && lastSetTag != null && lastSetTag != Tag) multipleTags = true; + lastSetTag = Tag; + + // If this member is in a group, we have a (used) group and we flag that + if (GroupId != null) hasGroup = true; + + // Brackets in Tupperbox format are arranged as a single array + // [prefix1, suffix1, prefix2, suffix2, prefix3... etc] + // If there are more than two entries this member has multiple brackets and we flag that + if (Brackets.Count > 2) hasMultiBrackets = true; + + return new DataFileMember + { + Name = Name, + AvatarUrl = AvatarUrl, + Birthday = Birthday, + Description = Description, + Prefix = Brackets.FirstOrDefault(), + Suffix = Brackets.Skip(1).FirstOrDefault() // TODO: can Tupperbox members have no proxies at all? + }; + } + } + + public struct TupperboxGroup + { + [JsonProperty("id")] public int Id; + [JsonProperty("name")] public string Name; + [JsonProperty("description")] public string Description; + [JsonProperty("tag")] public string Tag; + + [JsonIgnore] public bool Valid => true; } } \ No newline at end of file From e66c8152952be7f55b431fda932b7d80a9038d92 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sat, 15 Jun 2019 11:55:40 +0200 Subject: [PATCH 045/103] Remove unused function --- PluralKit.Bot/Commands/ImportExportCommands.cs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/PluralKit.Bot/Commands/ImportExportCommands.cs b/PluralKit.Bot/Commands/ImportExportCommands.cs index 5e10e1e2..826c8183 100644 --- a/PluralKit.Bot/Commands/ImportExportCommands.cs +++ b/PluralKit.Bot/Commands/ImportExportCommands.cs @@ -113,19 +113,5 @@ namespace PluralKit.Bot.Commands await Context.Channel.SendFileAsync(stream, "system.json", $"{Emojis.Success} Here you go!"); }); } - - private DataFileSystem? TryDeserialize(string json) - { - try - { - - } - catch (JsonException e) - { - Console.WriteLine("uww"); - } - - return null; - } } } \ No newline at end of file From 5d15a973f1b84454da9e3603865611f79b373a6e Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sat, 15 Jun 2019 12:03:07 +0200 Subject: [PATCH 046/103] Add customization of invite link client ID. Closes #77. --- PluralKit.Bot/BotConfig.cs | 1 + PluralKit.Bot/Commands/MiscCommands.cs | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/PluralKit.Bot/BotConfig.cs b/PluralKit.Bot/BotConfig.cs index 87c2035a..d941fc70 100644 --- a/PluralKit.Bot/BotConfig.cs +++ b/PluralKit.Bot/BotConfig.cs @@ -3,5 +3,6 @@ namespace PluralKit.Bot public class BotConfig { public string Token { get; set; } + public ulong? ClientId { get; set; } } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/MiscCommands.cs b/PluralKit.Bot/Commands/MiscCommands.cs index d678c701..13b1e29c 100644 --- a/PluralKit.Bot/Commands/MiscCommands.cs +++ b/PluralKit.Bot/Commands/MiscCommands.cs @@ -4,11 +4,13 @@ using Discord.Commands; namespace PluralKit.Bot.Commands { public class MiscCommands: ModuleBase<PKCommandContext> { + public BotConfig BotConfig { get; set; } + [Command("invite")] [Remarks("invite")] - public async Task Invite() { - var info = await Context.Client.GetApplicationInfoAsync(); - + public async Task Invite() + { + var clientId = BotConfig.ClientId ?? (await Context.Client.GetApplicationInfoAsync()).Id; var permissions = new GuildPermissions( addReactions: true, attachFiles: true, @@ -20,7 +22,7 @@ namespace PluralKit.Bot.Commands { ); // TODO: allow customization of invite ID - var invite = $"https://discordapp.com/oauth2/authorize?client_id={info.Id}&scope=bot&permissions={permissions.RawValue}"; + var invite = $"https://discordapp.com/oauth2/authorize?client_id={clientId}&scope=bot&permissions={permissions.RawValue}"; await Context.Channel.SendMessageAsync($"{Emojis.Success} Use this link to add PluralKit to your server:\n<{invite}>"); } } From fa5a6167169a61e57ec7a8d06e7c2bbff10b88ea Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sat, 15 Jun 2019 12:19:44 +0200 Subject: [PATCH 047/103] Add system fronter command --- PluralKit.Bot/Commands/SystemCommands.cs | 17 +++++++++++++++++ PluralKit.Bot/Services/EmbedService.cs | 15 +++++++++++++-- PluralKit.Bot/Utils.cs | 8 ++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index 491879d4..2de9d0b3 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -20,7 +20,10 @@ namespace PluralKit.Bot.Commands public SystemStore Systems {get; set;} public MemberStore Members {get; set;} + + public SwitchStore Switches {get; set;} public EmbedService EmbedService {get; set;} + [Command] public async Task Query(PKSystem system = null) { @@ -145,8 +148,22 @@ namespace PluralKit.Bot.Commands } } + [Command("fronter")] + public async Task SystemFronter() + { + var system = ContextEntity ?? Context.SenderSystem; + if (system == null) throw Errors.NoSystemError; + + var sw = await Switches.GetLatestSwitch(system); + if (sw == null) throw Errors.NoRegisteredSwitches; + + var members = await Switches.GetSwitchMembers(sw); + await Context.Channel.SendMessageAsync(embed: EmbedService.CreateFronterEmbed(sw, members.ToList())); + } + [Command("timezone")] [Remarks("system timezone [timezone]")] + [MustHaveSystem] public async Task SystemTimezone([Remainder] string zoneStr = null) { if (zoneStr == null) diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 3c1af3f3..edbf0fad 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -1,7 +1,9 @@ +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading.Tasks; using Discord; +using NodaTime; namespace PluralKit.Bot { public class EmbedService { @@ -50,8 +52,7 @@ namespace PluralKit.Bot { var name = member.Name; if (system.Name != null) name = $"{member.Name} ({system.Name})"; - var color = Color.Default; - if (member.Color != null) color = new Color(uint.Parse(member.Color, NumberStyles.HexNumber)); + var color = member.Color?.ToDiscordColor() ?? Color.Default; var messageCount = await _members.MessageCount(member); @@ -69,5 +70,15 @@ namespace PluralKit.Bot { return eb.Build(); } + + public Embed CreateFronterEmbed(PKSwitch sw, ICollection<PKMember> members) + { + var timeSinceSwitch = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp; + return new EmbedBuilder() + .WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? Color.Blue) + .AddField("Current fronter", members.Count > 0 ? string.Join(", ", members.Select(m => m.Name)) : "*(no fronter)*", true) + .AddField("Since", $"{sw.Timestamp.ToString(Formats.DateTimeFormat, null)} ({timeSinceSwitch.ToString(Formats.DurationFormat, null)} ago)", true) + .Build(); + } } } \ No newline at end of file diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index 726737b6..606ad81d 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Data; +using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading.Tasks; @@ -20,6 +21,13 @@ namespace PluralKit.Bot return $"{user.Username}#{user.Discriminator} ({user.Mention})"; } + public static Color? ToDiscordColor(this string color) + { + if (uint.TryParse(color, NumberStyles.HexNumber, null, out var colorInt)) + return new Color(colorInt); + throw new ArgumentException($"Invalid color string '{color}'."); + } + public static async Task VerifyAvatarOrThrow(string url) { // List of MIME types we consider acceptable From f4a53ce815fbe7bd6be2d9a0405a1160b0b75535 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sat, 15 Jun 2019 12:33:24 +0200 Subject: [PATCH 048/103] Refactor date/time format constants --- PluralKit.Bot/Commands/SwitchCommands.cs | 12 ++++++------ PluralKit.Bot/Commands/SystemCommands.cs | 4 ++-- PluralKit.Bot/Errors.cs | 2 +- PluralKit.Bot/Services/EmbedService.cs | 4 ++-- PluralKit.Core/DataFiles.cs | 11 +++++------ PluralKit.Core/Models.cs | 2 ++ PluralKit.Core/Utils.cs | 10 ++++++---- 7 files changed, 24 insertions(+), 21 deletions(-) diff --git a/PluralKit.Bot/Commands/SwitchCommands.cs b/PluralKit.Bot/Commands/SwitchCommands.cs index fcdb2126..d313263a 100644 --- a/PluralKit.Bot/Commands/SwitchCommands.cs +++ b/PluralKit.Bot/Commands/SwitchCommands.cs @@ -79,10 +79,10 @@ namespace PluralKit.Bot.Commands // But, we do a prompt to confirm. var lastSwitchMembers = await Switches.GetSwitchMembers(lastTwoSwitches[0]); var lastSwitchMemberStr = string.Join(", ", lastSwitchMembers.Select(m => m.Name)); - var lastSwitchTimeStr = lastTwoSwitches[0].Timestamp.ToString(Formats.DateTimeFormat, null); - var lastSwitchDeltaStr = SystemClock.Instance.GetCurrentInstant().Minus(lastTwoSwitches[0].Timestamp).ToString(Formats.DurationFormat, null); - var newSwitchTimeStr = time.ToString(Formats.DateTimeFormat, null); - var newSwitchDeltaStr = SystemClock.Instance.GetCurrentInstant().Minus(time.ToInstant()).ToString(Formats.DurationFormat, null); + var lastSwitchTimeStr = Formats.ZonedDateTimeFormat.Format(lastTwoSwitches[0].Timestamp.InZone(Context.SenderSystem.Zone)); + var lastSwitchDeltaStr = Formats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp); + var newSwitchTimeStr = Formats.ZonedDateTimeFormat.Format(time); + var newSwitchDeltaStr = Formats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - time.ToInstant()); // yeet var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} This will move the latest switch ({lastSwitchMemberStr}) from {lastSwitchTimeStr} ({lastSwitchDeltaStr} ago) to {newSwitchTimeStr} ({newSwitchDeltaStr} ago). Is this OK?"); @@ -104,7 +104,7 @@ namespace PluralKit.Bot.Commands var lastSwitchMembers = await Switches.GetSwitchMembers(lastTwoSwitches[0]); var lastSwitchMemberStr = string.Join(", ", lastSwitchMembers.Select(m => m.Name)); - var lastSwitchDeltaStr = SystemClock.Instance.GetCurrentInstant().Minus(lastTwoSwitches[0].Timestamp).ToString(Formats.DurationFormat, null); + var lastSwitchDeltaStr = Formats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp); IUserMessage msg; if (lastTwoSwitches.Length == 1) @@ -116,7 +116,7 @@ namespace PluralKit.Bot.Commands { var secondSwitchMembers = await Switches.GetSwitchMembers(lastTwoSwitches[1]); var secondSwitchMemberStr = string.Join(", ", secondSwitchMembers.Select(m => m.Name)); - var secondSwitchDeltaStr = SystemClock.Instance.GetCurrentInstant().Minus(lastTwoSwitches[1].Timestamp).ToString(Formats.DurationFormat, null); + var secondSwitchDeltaStr = Formats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[1].Timestamp); msg = await Context.Channel.SendMessageAsync( $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). The next latest switch is {secondSwitchMemberStr} ({secondSwitchDeltaStr} ago). Is this okay?"); } diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index 2de9d0b3..6678034c 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -158,7 +158,7 @@ namespace PluralKit.Bot.Commands if (sw == null) throw Errors.NoRegisteredSwitches; var members = await Switches.GetSwitchMembers(sw); - await Context.Channel.SendMessageAsync(embed: EmbedService.CreateFronterEmbed(sw, members.ToList())); + await Context.Channel.SendMessageAsync(embed: EmbedService.CreateFronterEmbed(sw, members.ToList(), system.Zone)); } [Command("timezone")] @@ -179,7 +179,7 @@ namespace PluralKit.Bot.Commands var currentTime = SystemClock.Instance.GetCurrentInstant().InZone(zone); var msg = await Context.Channel.SendMessageAsync( - $"This will change the system time zone to {zone.Id}. The current time is {currentTime.ToString(Formats.DateTimeFormat, null)}. Is this correct?"); + $"This will change the system time zone to {zone.Id}. The current time is {Formats.ZonedDateTimeFormat.Format(currentTime)}. Is this correct?"); if (!await Context.PromptYesNo(msg)) throw Errors.TimezoneChangeCancelled; Context.SenderSystem.UiTz = zone.Id; await Systems.Save(Context.SenderSystem); diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index edff5176..46c737bb 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -53,7 +53,7 @@ namespace PluralKit.Bot { public static PKError SwitchTimeInFuture => new PKError("Can't move switch to a time in the future."); public static PKError NoRegisteredSwitches => new PKError("There are no registered switches for this system."); - public static PKError SwitchMoveBeforeSecondLast(ZonedDateTime time) => new PKError($"Can't move switch to before last switch time ({time.ToString(Formats.DateTimeFormat, null)}), as it would cause conflicts."); + public static PKError SwitchMoveBeforeSecondLast(ZonedDateTime time) => new PKError($"Can't move switch to before last switch time ({Formats.ZonedDateTimeFormat.Format(time)}), as it would cause conflicts."); public static PKError SwitchMoveCancelled => new PKError("Switch move cancelled."); public static PKError SwitchDeleteCancelled => new PKError("Switch deletion cancelled."); public static PKError TimezoneParseError(string timezone) => new PKError($"Could not parse timezone offset {timezone}. Offset must be a value like 'UTC+5' or 'GMT-4:30'."); diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index edbf0fad..941e7a20 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -71,13 +71,13 @@ namespace PluralKit.Bot { return eb.Build(); } - public Embed CreateFronterEmbed(PKSwitch sw, ICollection<PKMember> members) + public Embed CreateFronterEmbed(PKSwitch sw, ICollection<PKMember> members, DateTimeZone zone) { var timeSinceSwitch = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp; return new EmbedBuilder() .WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? Color.Blue) .AddField("Current fronter", members.Count > 0 ? string.Join(", ", members.Select(m => m.Name)) : "*(no fronter)*", true) - .AddField("Since", $"{sw.Timestamp.ToString(Formats.DateTimeFormat, null)} ({timeSinceSwitch.ToString(Formats.DurationFormat, null)} ago)", true) + .AddField("Since", $"{Formats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(zone))} ({Formats.DurationFormat.Format(timeSinceSwitch)} ago)", true) .Build(); } } diff --git a/PluralKit.Core/DataFiles.cs b/PluralKit.Core/DataFiles.cs index 5bcdaf3a..1b314c6b 100644 --- a/PluralKit.Core/DataFiles.cs +++ b/PluralKit.Core/DataFiles.cs @@ -38,7 +38,7 @@ namespace PluralKit.Bot TimeZone = system.UiTz, Members = members, Switches = switches, - Created = system.Created.ToString(Formats.TimestampExportFormat, null), + Created = Formats.TimestampExportFormat.Format(system.Created), LinkedAccounts = (await _systems.GetLinkedAccountIds(system)).ToList() }; } @@ -48,20 +48,20 @@ namespace PluralKit.Bot Id = member.Hid, Name = member.Name, Description = member.Description, - Birthday = member.Birthday?.ToString(Formats.DateExportFormat, null), + Birthday = member.Birthday != null ? Formats.DateExportFormat.Format(member.Birthday.Value) : null, Pronouns = member.Pronouns, Color = member.Color, AvatarUrl = member.AvatarUrl, Prefix = member.Prefix, Suffix = member.Suffix, - Created = member.Created.ToString(Formats.TimestampExportFormat, null), + Created = Formats.TimestampExportFormat.Format(member.Created), MessageCount = await _members.MessageCount(member) }; private async Task<DataFileSwitch> ExportSwitch(PKSwitch sw) => new DataFileSwitch { Members = (await _switches.GetSwitchMembers(sw)).Select(m => m.Hid).ToList(), - Timestamp = sw.Timestamp.ToString(Formats.TimestampExportFormat, null) + Timestamp = Formats.TimestampExportFormat.Format(sw.Timestamp) }; public async Task<ImportResult> ImportSystem(DataFileSystem data, PKSystem system) @@ -120,8 +120,7 @@ namespace PluralKit.Bot if (dataMember.Birthday != null) { - var birthdayParse = LocalDatePattern.CreateWithInvariantCulture(Formats.DateExportFormat) - .Parse(dataMember.Birthday); + var birthdayParse = Formats.DateExportFormat.Parse(dataMember.Birthday); member.Birthday = birthdayParse.Success ? (LocalDate?) birthdayParse.Value : null; } diff --git a/PluralKit.Core/Models.cs b/PluralKit.Core/Models.cs index e4989b6f..b947d1ce 100644 --- a/PluralKit.Core/Models.cs +++ b/PluralKit.Core/Models.cs @@ -18,6 +18,8 @@ namespace PluralKit public string UiTz { get; set; } public int MaxMemberNameLength => Tag != null ? 32 - Tag.Length - 1 : 32; + + public DateTimeZone Zone => DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz); } public class PKMember diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index 35c75b0d..3298a49f 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -223,9 +223,11 @@ namespace PluralKit public static class Formats { - public static string DateTimeFormat = "yyyy-MM-dd HH:mm:ss"; - public static string DateExportFormat = "yyyy-MM-dd"; - public static string TimestampExportFormat = "g"; - public static string DurationFormat = "D'd' h'h' m'm' s's'"; + public static InstantPattern InstantDateTimeFormat = InstantPattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss"); + public static InstantPattern TimestampExportFormat = InstantPattern.CreateWithInvariantCulture("g"); + public static LocalDatePattern DateExportFormat = LocalDatePattern.CreateWithInvariantCulture("yyyy-MM-dd"); + public static DurationPattern DurationFormat = DurationPattern.CreateWithInvariantCulture("D'd' h'h' m'm' s's'"); + public static LocalDateTimePattern LocalDateTimeFormat = LocalDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss"); + public static ZonedDateTimePattern ZonedDateTimeFormat = ZonedDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss x", DateTimeZoneProviders.Tzdb); } } \ No newline at end of file From 5dafc4fbd4bd1ec285875c14d30706da60cc7080 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sat, 15 Jun 2019 12:43:35 +0200 Subject: [PATCH 049/103] Add front history command --- PluralKit.Bot/Commands/SystemCommands.cs | 15 +++++++- PluralKit.Bot/Services/EmbedService.cs | 47 +++++++++++++++++++++--- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index 6678034c..5c81660f 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -157,8 +157,19 @@ namespace PluralKit.Bot.Commands var sw = await Switches.GetLatestSwitch(system); if (sw == null) throw Errors.NoRegisteredSwitches; - var members = await Switches.GetSwitchMembers(sw); - await Context.Channel.SendMessageAsync(embed: EmbedService.CreateFronterEmbed(sw, members.ToList(), system.Zone)); + await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateFronterEmbed(sw, system.Zone)); + } + + [Command("fronthistory")] + public async Task SystemFrontHistory() + { + var system = ContextEntity ?? Context.SenderSystem; + if (system == null) throw Errors.NoSystemError; + + var sws = (await Switches.GetSwitches(system, 10)).ToList(); + if (sws.Count == 0) throw Errors.NoRegisteredSwitches; + + await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateFrontHistoryEmbed(sws, system.Zone)); } [Command("timezone")] diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 941e7a20..e7d6fb4c 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -9,13 +9,15 @@ namespace PluralKit.Bot { public class EmbedService { private SystemStore _systems; private MemberStore _members; + private SwitchStore _switches; private IDiscordClient _client; - public EmbedService(SystemStore systems, MemberStore members, IDiscordClient client) + public EmbedService(SystemStore systems, MemberStore members, IDiscordClient client, SwitchStore switches) { - this._systems = systems; - this._members = members; - this._client = client; + _systems = systems; + _members = members; + _client = client; + _switches = switches; } public async Task<Embed> CreateSystemEmbed(PKSystem system) { @@ -71,8 +73,9 @@ namespace PluralKit.Bot { return eb.Build(); } - public Embed CreateFronterEmbed(PKSwitch sw, ICollection<PKMember> members, DateTimeZone zone) + public async Task<Embed> CreateFronterEmbed(PKSwitch sw, DateTimeZone zone) { + var members = (await _switches.GetSwitchMembers(sw)).ToList(); var timeSinceSwitch = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp; return new EmbedBuilder() .WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? Color.Blue) @@ -80,5 +83,39 @@ namespace PluralKit.Bot { .AddField("Since", $"{Formats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(zone))} ({Formats.DurationFormat.Format(timeSinceSwitch)} ago)", true) .Build(); } + + public async Task<Embed> CreateFrontHistoryEmbed(IEnumerable<PKSwitch> sws, DateTimeZone zone) + { + var outputStr = ""; + + PKSwitch lastSw = null; + foreach (var sw in sws) + { + // Fetch member list and format + var members = (await _switches.GetSwitchMembers(sw)).ToList(); + var membersStr = members.Any() ? string.Join(", ", members.Select(m => m.Name)) : "no fronter"; + + var switchSince = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp; + + // If this isn't the latest switch, we also show duration + if (lastSw != null) + { + // Calculate the time between the last switch (that we iterated - ie. the next one on the timeline) and the current one + var switchDuration = lastSw.Timestamp - sw.Timestamp; + outputStr += $"**{membersStr}** ({Formats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(zone))}, {Formats.DurationFormat.Format(switchSince)} ago, for {Formats.DurationFormat.Format(switchDuration)})\n"; + } + else + { + outputStr += $"**{membersStr}** ({Formats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(zone))}, {Formats.DurationFormat.Format(switchSince)} ago)\n"; + } + + lastSw = sw; + } + + return new EmbedBuilder() + .WithTitle("Past switches") + .WithDescription(outputStr) + .Build(); + } } } \ No newline at end of file From 7a10a280198e37a13f00cb861359b68b15674f5c Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sat, 15 Jun 2019 12:49:30 +0200 Subject: [PATCH 050/103] Only show the two most significant delta-time components --- PluralKit.Core/Utils.cs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index 3298a49f..0026234a 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -223,11 +223,23 @@ namespace PluralKit public static class Formats { - public static InstantPattern InstantDateTimeFormat = InstantPattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss"); - public static InstantPattern TimestampExportFormat = InstantPattern.CreateWithInvariantCulture("g"); - public static LocalDatePattern DateExportFormat = LocalDatePattern.CreateWithInvariantCulture("yyyy-MM-dd"); - public static DurationPattern DurationFormat = DurationPattern.CreateWithInvariantCulture("D'd' h'h' m'm' s's'"); - public static LocalDateTimePattern LocalDateTimeFormat = LocalDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss"); - public static ZonedDateTimePattern ZonedDateTimeFormat = ZonedDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss x", DateTimeZoneProviders.Tzdb); + public static IPattern<Instant> TimestampExportFormat = InstantPattern.CreateWithInvariantCulture("g"); + public static IPattern<LocalDate> DateExportFormat = LocalDatePattern.CreateWithInvariantCulture("yyyy-MM-dd"); + public static IPattern<Duration> DurationFormat; + public static IPattern<LocalDateTime> LocalDateTimeFormat = LocalDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss"); + public static IPattern<ZonedDateTime> ZonedDateTimeFormat = ZonedDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss x", DateTimeZoneProviders.Tzdb); + + static Formats() + { + // We create a composite pattern that only shows the two most significant things + // eg. if we have something with nonzero day component, we show <x>d <x>h, but if it's + // a smaller duration we may only bother with showing <x>h <x>m or <x>m <x>s + var compositeDuration = new CompositePatternBuilder<Duration>(); + compositeDuration.Add(DurationPattern.CreateWithInvariantCulture("D'd' h'h'"), d => d.Days > 0); + compositeDuration.Add(DurationPattern.CreateWithInvariantCulture("H'h' m'm'"), d => d.Hours > 0); + compositeDuration.Add(DurationPattern.CreateWithInvariantCulture("m'm' s's'"), d => d.Minutes > 0); + compositeDuration.Add(DurationPattern.CreateWithInvariantCulture("s's'"), d => true); + DurationFormat = compositeDuration.Build(); + } } } \ No newline at end of file From 06edc9d61e61a7f7ab8f7f4d111a9ffc7b845086 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Thu, 20 Jun 2019 21:15:57 +0200 Subject: [PATCH 051/103] Add API token commands --- PluralKit.Bot/Commands/APICommands.cs | 66 +++++++++++++++++++++++++++ PluralKit.Core/Utils.cs | 37 ++++++++------- 2 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 PluralKit.Bot/Commands/APICommands.cs diff --git a/PluralKit.Bot/Commands/APICommands.cs b/PluralKit.Bot/Commands/APICommands.cs new file mode 100644 index 00000000..426646a3 --- /dev/null +++ b/PluralKit.Bot/Commands/APICommands.cs @@ -0,0 +1,66 @@ +using System.Threading.Tasks; +using Discord; +using Discord.Commands; + +namespace PluralKit.Bot.Commands +{ + [Group("token")] + public class APICommands: ModuleBase<PKCommandContext> + { + public SystemStore Systems { get; set; } + + [Command] + [MustHaveSystem] + [Remarks("token")] + public async Task GetToken() + { + // Get or make a token + var token = Context.SenderSystem.Token ?? await MakeAndSetNewToken(); + + // If we're not already in a DM, reply with a reminder to check + if (!(Context.Channel is IDMChannel)) + { + await Context.Channel.SendMessageAsync($"{Emojis.Success} Check your DMs!"); + } + + // DM the user a security disclaimer, and then the token in a separate message (for easy copying on mobile) + await Context.User.SendMessageAsync($"{Emojis.Warn} Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure. If it leaks or you need a new one, you can invalidate this one with `pk;token refresh`.\n\nYour token is below:"); + await Context.User.SendMessageAsync(token); + } + + private async Task<string> MakeAndSetNewToken() + { + Context.SenderSystem.Token = PluralKit.Utils.GenerateToken(); + await Systems.Save(Context.SenderSystem); + return Context.SenderSystem.Token; + } + + [Command("refresh")] + [MustHaveSystem] + [Alias("expire", "invalidate", "update", "new")] + [Remarks("token refresh")] + public async Task RefreshToken() + { + if (Context.SenderSystem.Token == null) + { + // If we don't have a token, call the other method instead + // This does pretty much the same thing, except words the messages more appropriately for that :) + await GetToken(); + return; + } + + // Make a new token from scratch + var token = await MakeAndSetNewToken(); + + // If we're not already in a DM, reply with a reminder to check + if (!(Context.Channel is IDMChannel)) + { + await Context.Channel.SendMessageAsync($"{Emojis.Success} Check your DMs!"); + } + + // DM the user an invalidation disclaimer, and then the token in a separate message (for easy copying on mobile) + await Context.User.SendMessageAsync($"{Emojis.Warn} Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\n\nYour token is below:"); + await Context.User.SendMessageAsync(token); + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index 0026234a..24871607 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -1,8 +1,6 @@ using System; -using System.Collections; -using System.Collections.Generic; -using System.Globalization; using System.Linq; +using System.Security.Cryptography; using System.Text.RegularExpressions; using NodaTime; using NodaTime.Text; @@ -24,6 +22,13 @@ namespace PluralKit return hid; } + public static string GenerateToken() + { + var buf = new byte[48]; // Results in a 64-byte Base64 string (no padding) + new RNGCryptoServiceProvider().GetBytes(buf); + return Convert.ToBase64String(buf); + } + 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; @@ -225,21 +230,19 @@ namespace PluralKit { public static IPattern<Instant> TimestampExportFormat = InstantPattern.CreateWithInvariantCulture("g"); public static IPattern<LocalDate> DateExportFormat = LocalDatePattern.CreateWithInvariantCulture("yyyy-MM-dd"); - public static IPattern<Duration> DurationFormat; + + // We create a composite pattern that only shows the two most significant things + // eg. if we have something with nonzero day component, we show <x>d <x>h, but if it's + // a smaller duration we may only bother with showing <x>h <x>m or <x>m <x>s + public static IPattern<Duration> DurationFormat = new CompositePatternBuilder<Duration> + { + {DurationPattern.CreateWithInvariantCulture("D'd' h'h'"), d => d.Days > 0}, + {DurationPattern.CreateWithInvariantCulture("H'h' m'm'"), d => d.Hours > 0}, + {DurationPattern.CreateWithInvariantCulture("m'm' s's'"), d => d.Minutes > 0}, + {DurationPattern.CreateWithInvariantCulture("s's'"), d => true} + }.Build(); + public static IPattern<LocalDateTime> LocalDateTimeFormat = LocalDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss"); public static IPattern<ZonedDateTime> ZonedDateTimeFormat = ZonedDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss x", DateTimeZoneProviders.Tzdb); - - static Formats() - { - // We create a composite pattern that only shows the two most significant things - // eg. if we have something with nonzero day component, we show <x>d <x>h, but if it's - // a smaller duration we may only bother with showing <x>h <x>m or <x>m <x>s - var compositeDuration = new CompositePatternBuilder<Duration>(); - compositeDuration.Add(DurationPattern.CreateWithInvariantCulture("D'd' h'h'"), d => d.Days > 0); - compositeDuration.Add(DurationPattern.CreateWithInvariantCulture("H'h' m'm'"), d => d.Hours > 0); - compositeDuration.Add(DurationPattern.CreateWithInvariantCulture("m'm' s's'"), d => d.Minutes > 0); - compositeDuration.Add(DurationPattern.CreateWithInvariantCulture("s's'"), d => true); - DurationFormat = compositeDuration.Build(); - } } } \ No newline at end of file From 2c3c46002ab356654ab4132a97961488159c5c25 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Fri, 21 Jun 2019 13:49:58 +0200 Subject: [PATCH 052/103] Add message lookup and log channel setting commands --- PluralKit.Bot/Commands/ModCommands.cs | 42 +++++++++++++++++++++ PluralKit.Bot/Errors.cs | 1 + PluralKit.Bot/Services/EmbedService.cs | 22 ++++++++++- PluralKit.Bot/Services/LogChannelService.cs | 14 +++---- PluralKit.Bot/Services/ProxyService.cs | 9 +++-- PluralKit.Core/Stores.cs | 23 +++++++---- 6 files changed, 92 insertions(+), 19 deletions(-) create mode 100644 PluralKit.Bot/Commands/ModCommands.cs diff --git a/PluralKit.Bot/Commands/ModCommands.cs b/PluralKit.Bot/Commands/ModCommands.cs new file mode 100644 index 00000000..8753bc9e --- /dev/null +++ b/PluralKit.Bot/Commands/ModCommands.cs @@ -0,0 +1,42 @@ +using System.Threading.Tasks; +using Discord; +using Discord.Commands; + +namespace PluralKit.Bot.Commands +{ + public class ModCommands: ModuleBase<PKCommandContext> + { + public LogChannelService LogChannels { get; set; } + public MessageStore Messages { get; set; } + + public EmbedService Embeds { get; set; } + + [Command("log")] + [Remarks("log <channel>")] + [RequireUserPermission(GuildPermission.ManageGuild, ErrorMessage = "You must have the Manage Server permission to use this command.")] + [RequireContext(ContextType.Guild, ErrorMessage = "This command can not be run in a DM.")] + public async Task SetLogChannel(ITextChannel channel = null) + { + await LogChannels.SetLogChannel(Context.Guild, channel); + + if (channel != null) + await Context.Channel.SendMessageAsync($"{Emojis.Success} Proxy logging channel set to #{channel.Name}."); + else + await Context.Channel.SendMessageAsync($"{Emojis.Success} Proxy logging channel cleared."); + } + + [Command("message")] + [Remarks("message <messageid>")] + public async Task GetMessage(ulong messageId) + { + var message = await Messages.Get(messageId); + if (message == null) throw Errors.MessageNotFound(messageId); + + await Context.Channel.SendMessageAsync(embed: await Embeds.CreateMessageInfoEmbed(messageId)); + } + + [Command("message")] + [Remarks("message <messageid>")] + public async Task GetMessage(IMessage msg) => await GetMessage(msg.Id); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 46c737bb..0d2b78b5 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -64,5 +64,6 @@ namespace PluralKit.Bot { public static PKError NoImportFilePassed => new PKError("You must either pass an URL to a file as a command parameter, or as an attachment to the message containing the command."); public static PKError InvalidImportFile => new PKError("Imported data file invalid. Make sure this is a .json file directly exported from PluralKit or Tupperbox."); public static PKError ImportCancelled => new PKError("Import cancelled."); + public static PKError MessageNotFound(ulong id) => new PKError($"Message with ID '{id}' not found. Are you sure it's a message proxied by PluralKit?"); } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index e7d6fb4c..bccfc4bc 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -10,14 +10,16 @@ namespace PluralKit.Bot { private SystemStore _systems; private MemberStore _members; private SwitchStore _switches; + private MessageStore _messages; private IDiscordClient _client; - public EmbedService(SystemStore systems, MemberStore members, IDiscordClient client, SwitchStore switches) + public EmbedService(SystemStore systems, MemberStore members, IDiscordClient client, SwitchStore switches, MessageStore messages) { _systems = systems; _members = members; _client = client; _switches = switches; + _messages = messages; } public async Task<Embed> CreateSystemEmbed(PKSystem system) { @@ -117,5 +119,23 @@ namespace PluralKit.Bot { .WithDescription(outputStr) .Build(); } + + public async Task<Embed> CreateMessageInfoEmbed(ulong messageId) + { + var msg = await _messages.Get(messageId); + var channel = (ITextChannel) await _client.GetChannelAsync(msg.Message.Channel); + var serverMsg = await channel.GetMessageAsync(msg.Message.Mid); + + var memberStr = $"{msg.Member.Name} (`{msg.Member.Hid}`)"; + if (msg.Member.Pronouns != null) memberStr += $"\n*(pronouns: **{msg.Member.Pronouns}**)*"; + + return new EmbedBuilder() + .WithAuthor(msg.Member.Name, msg.Member.AvatarUrl) + .WithDescription(serverMsg.Content) + .AddField("System", msg.System.Name != null ? $"{msg.System.Name} (`{msg.System.Hid}`)" : $"`{msg.System.Hid}`", true) + .AddField("Member", memberStr, true) + .WithTimestamp(SnowflakeUtils.FromSnowflake(msg.Message.Mid)) + .Build(); + } } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/LogChannelService.cs b/PluralKit.Bot/Services/LogChannelService.cs index 9f7b55c0..b2e9ccaf 100644 --- a/PluralKit.Bot/Services/LogChannelService.cs +++ b/PluralKit.Bot/Services/LogChannelService.cs @@ -4,12 +4,12 @@ using Dapper; using Discord; namespace PluralKit.Bot { - class ServerDefinition { - public ulong Id; - public ulong LogChannel; + public class ServerDefinition { + public ulong Id { get; set; } + public ulong LogChannel { get; set; } } - class LogChannelService { + public class LogChannelService { private IDiscordClient _client; private IDbConnection _connection; private EmbedService _embed; @@ -30,7 +30,7 @@ namespace PluralKit.Bot { } public async Task<ITextChannel> GetLogChannel(IGuild guild) { - var server = await _connection.QueryFirstAsync<ServerDefinition>("select * from servers where id = @Id", new { Id = guild.Id }); + var server = await _connection.QueryFirstOrDefaultAsync<ServerDefinition>("select * from servers where id = @Id", new { Id = guild.Id }); if (server == null) return null; return await _client.GetChannelAsync(server.LogChannel) as ITextChannel; } @@ -40,8 +40,8 @@ namespace PluralKit.Bot { Id = guild.Id, LogChannel = newLogChannel.Id }; - - await _connection.ExecuteAsync("insert into servers(id, log_channel) values (@Id, @LogChannel) on conflict (id) do update set log_channel = @LogChannel", def); + + await _connection.QueryAsync("insert into servers (id, log_channel) values (@Id, @LogChannel)", def); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index b58773f3..06b4d288 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -49,7 +49,10 @@ namespace PluralKit.Bot // Sort by specificity (ProxyString length desc = prefix+suffix length desc = inner message asc = more specific proxy first!) var ordered = potentials.OrderByDescending(p => p.Member.ProxyString.Length); - foreach (var potential in ordered) { + foreach (var potential in ordered) + { + if (potential.Member.Prefix == null && potential.Member.Suffix != null) continue; + var prefix = potential.Member.Prefix ?? ""; var suffix = potential.Member.Suffix ?? ""; @@ -62,7 +65,7 @@ namespace PluralKit.Bot } public async Task HandleMessageAsync(IMessage message) { - var results = await _connection.QueryAsync<PKMember, PKSystem, ProxyDatabaseResult>("select members.*, systems.* from members, systems, accounts where members.system = systems.id and accounts.system = systems.id and accounts.uid = @Uid and (members.prefix != null or members.suffix != null)", (member, system) => new ProxyDatabaseResult { Member = member, System = system }, new { Uid = message.Author.Id }); + var results = await _connection.QueryAsync<PKMember, PKSystem, ProxyDatabaseResult>("select members.*, systems.* from members, systems, accounts where members.system = systems.id and accounts.system = systems.id and accounts.uid = @Uid", (member, system) => new ProxyDatabaseResult { Member = member, System = system }, new { Uid = message.Author.Id }); // Find a member with proxy tags matching the message var match = GetProxyTagMatch(message.Content, results); @@ -105,7 +108,7 @@ namespace PluralKit.Bot if (storedMessage == null) return; // (if we can't, that's ok, no worries) // Make sure it's the actual sender of that message deleting the message - if (storedMessage.SenderId != reaction.UserId) return; + if (storedMessage.Message.Sender != reaction.UserId) return; try { // Then, fetch the Discord message and delete that diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index 14cb6483..199dc27f 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -114,10 +114,15 @@ namespace PluralKit { } public class MessageStore { - public class StoredMessage { + public struct PKMessage + { public ulong Mid; - public ulong ChannelId; - public ulong SenderId; + public ulong Channel; + public ulong Sender; + } + public class StoredMessage + { + public PKMessage Message; public PKMember Member; public PKSystem System; } @@ -137,11 +142,13 @@ namespace PluralKit { }); } - public async Task<StoredMessage> Get(ulong id) { - return (await _connection.QueryAsync<StoredMessage, PKMember, PKSystem, StoredMessage>("select * from messages, members, systems where mid = @Id and messages.member = members.id and systems.id = members.system", (msg, member, system) => { - msg.System = system; - msg.Member = member; - return msg; + public async Task<StoredMessage> Get(ulong id) + { + return (await _connection.QueryAsync<PKMessage, PKMember, PKSystem, StoredMessage>("select messages.*, members.*, systems.* from messages, members, systems where mid = @Id and messages.member = members.id and systems.id = members.system", (msg, member, system) => new StoredMessage + { + Message = msg, + System = system, + Member = member }, new { Id = id })).FirstOrDefault(); } From 6e7950722d5c2ab3fd35bdd3ae79c17b61ab52c9 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Fri, 21 Jun 2019 13:52:34 +0200 Subject: [PATCH 053/103] Fix log channel clearing --- PluralKit.Bot/Services/LogChannelService.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/PluralKit.Bot/Services/LogChannelService.cs b/PluralKit.Bot/Services/LogChannelService.cs index b2e9ccaf..93ccb638 100644 --- a/PluralKit.Bot/Services/LogChannelService.cs +++ b/PluralKit.Bot/Services/LogChannelService.cs @@ -6,7 +6,7 @@ using Discord; namespace PluralKit.Bot { public class ServerDefinition { public ulong Id { get; set; } - public ulong LogChannel { get; set; } + public ulong? LogChannel { get; set; } } public class LogChannelService { @@ -31,17 +31,17 @@ namespace PluralKit.Bot { public async Task<ITextChannel> GetLogChannel(IGuild guild) { var server = await _connection.QueryFirstOrDefaultAsync<ServerDefinition>("select * from servers where id = @Id", new { Id = guild.Id }); - if (server == null) return null; - return await _client.GetChannelAsync(server.LogChannel) as ITextChannel; + if (server?.LogChannel == null) return null; + return await _client.GetChannelAsync(server.LogChannel.Value) as ITextChannel; } public async Task SetLogChannel(IGuild guild, ITextChannel newLogChannel) { var def = new ServerDefinition { Id = guild.Id, - LogChannel = newLogChannel.Id + LogChannel = newLogChannel?.Id }; - await _connection.QueryAsync("insert into servers (id, log_channel) values (@Id, @LogChannel)", def); + await _connection.QueryAsync("insert into servers (id, log_channel) values (@Id, @LogChannel) on conflict (id) do update set log_channel = @LogChannel", def); } } } \ No newline at end of file From 93fff14053378d155321c4d4b6e34ff370170ce9 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Fri, 21 Jun 2019 13:53:19 +0200 Subject: [PATCH 054/103] Fix skipping proxying of members with no tags --- PluralKit.Bot/Services/ProxyService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index 06b4d288..01af085a 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -51,7 +51,7 @@ namespace PluralKit.Bot var ordered = potentials.OrderByDescending(p => p.Member.ProxyString.Length); foreach (var potential in ordered) { - if (potential.Member.Prefix == null && potential.Member.Suffix != null) continue; + if (potential.Member.Prefix == null && potential.Member.Suffix == null) continue; var prefix = potential.Member.Prefix ?? ""; var suffix = potential.Member.Suffix ?? ""; From 53037f7d52fa5a09365262d479a33acc79c2dbf9 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Fri, 21 Jun 2019 14:13:56 +0200 Subject: [PATCH 055/103] Add message querying by ? reaction --- PluralKit.Bot/Commands/ModCommands.cs | 2 +- PluralKit.Bot/Services/EmbedService.cs | 5 ++- PluralKit.Bot/Services/ProxyService.cs | 46 ++++++++++++++++++++------ 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/PluralKit.Bot/Commands/ModCommands.cs b/PluralKit.Bot/Commands/ModCommands.cs index 8753bc9e..9d1071fa 100644 --- a/PluralKit.Bot/Commands/ModCommands.cs +++ b/PluralKit.Bot/Commands/ModCommands.cs @@ -32,7 +32,7 @@ namespace PluralKit.Bot.Commands var message = await Messages.Get(messageId); if (message == null) throw Errors.MessageNotFound(messageId); - await Context.Channel.SendMessageAsync(embed: await Embeds.CreateMessageInfoEmbed(messageId)); + await Context.Channel.SendMessageAsync(embed: await Embeds.CreateMessageInfoEmbed(message)); } [Command("message")] diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index bccfc4bc..e960bc08 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -120,9 +120,8 @@ namespace PluralKit.Bot { .Build(); } - public async Task<Embed> CreateMessageInfoEmbed(ulong messageId) + public async Task<Embed> CreateMessageInfoEmbed(MessageStore.StoredMessage msg) { - var msg = await _messages.Get(messageId); var channel = (ITextChannel) await _client.GetChannelAsync(msg.Message.Channel); var serverMsg = await channel.GetMessageAsync(msg.Message.Mid); @@ -131,7 +130,7 @@ namespace PluralKit.Bot { return new EmbedBuilder() .WithAuthor(msg.Member.Name, msg.Member.AvatarUrl) - .WithDescription(serverMsg.Content) + .WithDescription(serverMsg?.Content ?? "*(message contents deleted or inaccessible)*") .AddField("System", msg.System.Name != null ? $"{msg.System.Name} (`{msg.System.Hid}`)" : $"`{msg.System.Hid}`", true) .AddField("Member", memberStr, true) .WithTimestamp(SnowflakeUtils.FromSnowflake(msg.Message.Mid)) diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index 01af085a..3b117d8b 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -33,15 +33,16 @@ namespace PluralKit.Bot private LogChannelService _logger; private WebhookCacheService _webhookCache; private MessageStore _messageStorage; - + private EmbedService _embeds; - public ProxyService(IDiscordClient client, WebhookCacheService webhookCache, IDbConnection connection, LogChannelService logger, MessageStore messageStorage) + public ProxyService(IDiscordClient client, WebhookCacheService webhookCache, IDbConnection connection, LogChannelService logger, MessageStore messageStorage, EmbedService embeds) { - this._client = client; - this._webhookCache = webhookCache; - this._connection = connection; - this._logger = logger; - this._messageStorage = messageStorage; + _client = client; + _webhookCache = webhookCache; + _connection = connection; + _logger = logger; + _messageStorage = messageStorage; + _embeds = embeds; } private ProxyMatch GetProxyTagMatch(string message, IEnumerable<ProxyDatabaseResult> potentials) { @@ -98,17 +99,40 @@ namespace PluralKit.Bot return await webhook.Channel.GetMessageAsync(messageId); } - public async Task HandleReactionAddedAsync(Cacheable<IUserMessage, ulong> message, ISocketMessageChannel channel, SocketReaction reaction) + public Task HandleReactionAddedAsync(Cacheable<IUserMessage, ulong> message, ISocketMessageChannel channel, SocketReaction reaction) { - // Make sure it's the right emoji (red X) - if (reaction.Emote.Name != "\u274C") return; + // Dispatch on emoji + switch (reaction.Emote.Name) + { + case "\u274C": // Red X + return HandleMessageDeletionByReaction(message, reaction.UserId); + case "\u2753": // Red question mark + case "\u2754": // White question mark + return HandleMessageQueryByReaction(message, reaction.UserId); + default: + return Task.CompletedTask; + } + } + private async Task HandleMessageQueryByReaction(Cacheable<IUserMessage, ulong> message, ulong userWhoReacted) + { + var user = await _client.GetUserAsync(userWhoReacted); + if (user == null) return; + + var msg = await _messageStorage.Get(message.Id); + if (msg == null) return; + + await user.SendMessageAsync(embed: await _embeds.CreateMessageInfoEmbed(msg)); + } + + public async Task HandleMessageDeletionByReaction(Cacheable<IUserMessage, ulong> message, ulong userWhoReacted) + { // Find the message in the database var storedMessage = await _messageStorage.Get(message.Id); if (storedMessage == null) return; // (if we can't, that's ok, no worries) // Make sure it's the actual sender of that message deleting the message - if (storedMessage.Message.Sender != reaction.UserId) return; + if (storedMessage.Message.Sender != userWhoReacted) return; try { // Then, fetch the Discord message and delete that From 7eeaea39fed9bb3175a4fc5a6cd9cb6f24633d80 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Thu, 27 Jun 2019 10:38:45 +0200 Subject: [PATCH 056/103] Proxy messages with a mention before tags --- PluralKit.Bot/Bot.cs | 11 +++++------ PluralKit.Bot/ContextUtils.cs | 1 + PluralKit.Bot/Services/ProxyService.cs | 18 ++++++++++++++---- PluralKit.Bot/Utils.cs | 12 ++++++++++++ 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 73392101..164249ea 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -83,16 +83,14 @@ namespace PluralKit.Bot private IServiceProvider _services; private DiscordSocketClient _client; private CommandService _commands; - private IDbConnection _connection; private ProxyService _proxy; private Timer _updateTimer; - public Bot(IServiceProvider services, IDiscordClient client, CommandService commands, IDbConnection connection, ProxyService proxy) + public Bot(IServiceProvider services, IDiscordClient client, CommandService commands, ProxyService proxy) { this._services = services; this._client = client as DiscordSocketClient; this._commands = commands; - this._connection = connection; this._proxy = proxy; } @@ -120,7 +118,7 @@ namespace PluralKit.Bot private async Task Ready() { - _updateTimer = new Timer((_) => this.UpdatePeriodic(), null, 0, 60*1000); + _updateTimer = new Timer((_) => UpdatePeriodic(), null, 0, 60*1000); Console.WriteLine($"Shard #{_client.ShardId} connected to {_client.Guilds.Sum(g => g.Channels.Count)} channels in {_client.Guilds.Count} guilds."); Console.WriteLine($"PluralKit started as {_client.CurrentUser.Username}#{_client.CurrentUser.Discriminator} ({_client.CurrentUser.Id})."); @@ -170,8 +168,9 @@ namespace PluralKit.Bot // If it does, fetch the sender's system (because most commands need that) into the context, // and start command execution // Note system may be null if user has no system, hence `OrDefault` - var system = await _connection.QueryFirstOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id }); - await _commands.ExecuteAsync(new PKCommandContext(_client, arg, _connection, system), argPos, serviceScope.ServiceProvider); + var connection = serviceScope.ServiceProvider.GetService<IDbConnection>(); + var system = await connection.QueryFirstOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id }); + await _commands.ExecuteAsync(new PKCommandContext(_client, arg, connection, system), argPos, serviceScope.ServiceProvider); } else { diff --git a/PluralKit.Bot/ContextUtils.cs b/PluralKit.Bot/ContextUtils.cs index 1007ddc8..39a4ead1 100644 --- a/PluralKit.Bot/ContextUtils.cs +++ b/PluralKit.Bot/ContextUtils.cs @@ -67,6 +67,7 @@ namespace PluralKit.Bot { } var msg = await ctx.Channel.SendMessageAsync(embed: MakeEmbedForPage(0)); + if (pageCount == 1) return; // If we only have one page, don't bother with the reaction/pagination logic, lol var botEmojis = new[] { new Emoji("\u23EA"), new Emoji("\u2B05"), new Emoji("\u27A1"), new Emoji("\u23E9"), new Emoji(Emojis.Error) }; await msg.AddReactionsAsync(botEmojis); diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index 3b117d8b..065f1bcc 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Data; using System.Linq; @@ -7,7 +6,6 @@ using System.Net; using System.Threading.Tasks; using Dapper; using Discord; -using Discord.Rest; using Discord.Webhook; using Discord.WebSocket; @@ -45,8 +43,18 @@ namespace PluralKit.Bot _embeds = embeds; } - private ProxyMatch GetProxyTagMatch(string message, IEnumerable<ProxyDatabaseResult> potentials) { - // TODO: add detection of leading @mention + private ProxyMatch GetProxyTagMatch(string message, IEnumerable<ProxyDatabaseResult> potentials) + { + // If the message starts with a @mention, and then proceeds to have proxy tags, + // extract the mention and place it inside the inner message + // eg. @Ske [text] => [@Ske text] + int matchStartPosition = 0; + string leadingMention = null; + if (Utils.HasMentionPrefix(message, ref matchStartPosition)) + { + leadingMention = message.Substring(0, matchStartPosition); + message = message.Substring(matchStartPosition); + } // Sort by specificity (ProxyString length desc = prefix+suffix length desc = inner message asc = more specific proxy first!) var ordered = potentials.OrderByDescending(p => p.Member.ProxyString.Length); @@ -59,9 +67,11 @@ namespace PluralKit.Bot if (message.StartsWith(prefix) && message.EndsWith(suffix)) { var inner = message.Substring(prefix.Length, message.Length - prefix.Length - suffix.Length); + if (leadingMention != null) inner = $"{leadingMention} {inner}"; return new ProxyMatch { Member = potential.Member, System = potential.System, InnerText = inner }; } } + return null; } diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index 606ad81d..e1fc6531 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -69,6 +69,18 @@ namespace PluralKit.Bot throw Errors.AvatarDimensionsTooLarge(image.Width, image.Height); } } + + public static bool HasMentionPrefix(string content, ref int argPos) + { + // Roughly ported from Discord.Commands.MessageExtensions.HasMentionPrefix + if (string.IsNullOrEmpty(content) || content.Length <= 3 || (content[0] != '<' || content[1] != '@')) + return false; + int num = content.IndexOf('>'); + if (num == -1 || content.Length < num + 2 || content[num + 1] != ' ' || !MentionUtils.TryParseUser(content.Substring(0, num + 1), out _)) + return false; + argPos = num + 2; + return true; + } } class PKSystemTypeReader : TypeReader From 42147fd9cc4d4428de727058a91930644ff98632 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sun, 30 Jun 2019 23:41:01 +0200 Subject: [PATCH 057/103] Add front percent command --- PluralKit.Bot/Commands/SystemCommands.cs | 16 +++++ PluralKit.Bot/Errors.cs | 2 + PluralKit.Bot/Services/EmbedService.cs | 27 ++++++++ PluralKit.Core/Stores.cs | 79 +++++++++++++++++++++++- PluralKit.Core/Utils.cs | 18 +++++- 5 files changed, 138 insertions(+), 4 deletions(-) diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index 5c81660f..6a964324 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -172,6 +172,22 @@ namespace PluralKit.Bot.Commands await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateFrontHistoryEmbed(sws, system.Zone)); } + [Command("frontpercent")] + public async Task SystemFrontPercent(string durationStr = "30d") + { + var system = ContextEntity ?? Context.SenderSystem; + if (system == null) throw Errors.NoSystemError; + + var duration = PluralKit.Utils.ParsePeriod(durationStr); + if (duration == null) throw Errors.InvalidDateTime(durationStr); + + var rangeEnd = SystemClock.Instance.GetCurrentInstant(); + var rangeStart = rangeEnd - duration.Value; + + var frontpercent = await Switches.GetPerMemberSwitchDuration(system, rangeEnd - duration.Value, rangeEnd); + await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateFrontPercentEmbed(frontpercent, rangeStart.InZone(system.Zone))); + } + [Command("timezone")] [Remarks("system timezone [timezone]")] [MustHaveSystem] diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 0d2b78b5..5cd76df9 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -65,5 +65,7 @@ namespace PluralKit.Bot { public static PKError InvalidImportFile => new PKError("Imported data file invalid. Make sure this is a .json file directly exported from PluralKit or Tupperbox."); public static PKError ImportCancelled => new PKError("Import cancelled."); public static PKError MessageNotFound(ulong id) => new PKError($"Message with ID '{id}' not found. Are you sure it's a message proxied by PluralKit?"); + + public static PKError DurationParseError(string durationStr) => new PKError($"Could not parse '{durationStr}' as a valid duration. Try a format such as `30d`, `1d3h` or `20m30s`."); } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index e960bc08..86a30b2b 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -136,5 +136,32 @@ namespace PluralKit.Bot { .WithTimestamp(SnowflakeUtils.FromSnowflake(msg.Message.Mid)) .Build(); } + + public async Task<Embed> CreateFrontPercentEmbed(IDictionary<PKMember, Duration> frontpercent, ZonedDateTime startingFrom) + { + var totalDuration = SystemClock.Instance.GetCurrentInstant() - startingFrom.ToInstant(); + + var eb = new EmbedBuilder() + .WithColor(Color.Blue) + .WithFooter($"Since {Formats.ZonedDateTimeFormat.Format(startingFrom)} ({Formats.DurationFormat.Format(totalDuration)} ago)"); + + var maxEntriesToDisplay = 24; // max 25 fields allowed in embed - reserve 1 for "others" + + var membersOrdered = frontpercent.OrderBy(pair => pair.Value).Take(maxEntriesToDisplay).ToList(); + foreach (var pair in membersOrdered) + { + var frac = pair.Value / totalDuration; + eb.AddField(pair.Key.Name, $"{frac*100:F0}% ({Formats.DurationFormat.Format(pair.Value)})"); + } + + if (membersOrdered.Count > maxEntriesToDisplay) + { + eb.AddField("(others)", + Formats.DurationFormat.Format(membersOrdered.Skip(maxEntriesToDisplay) + .Aggregate(Duration.Zero, (prod, next) => prod + next.Value)), true); + } + + return eb.Build(); + } } } \ No newline at end of file diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index 199dc27f..012794fa 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -189,13 +189,19 @@ namespace PluralKit { } } - public async Task<IEnumerable<PKSwitch>> GetSwitches(PKSystem system, int count) + public async Task<IEnumerable<PKSwitch>> GetSwitches(PKSystem system, int count = 9999999) { // TODO: refactor the PKSwitch data structure to somehow include a hydrated member list // (maybe when we get caching in?) return await _connection.QueryAsync<PKSwitch>("select * from switches where system = @System order by timestamp desc limit @Count", new {System = system.Id, Count = count}); } + public async Task<IEnumerable<int>> GetSwitchMemberIds(PKSwitch sw) + { + return await _connection.QueryAsync<int>("select member from switch_members where switch = @Switch", + new {Switch = sw.Id}); + } + public async Task<IEnumerable<PKMember>> GetSwitchMembers(PKSwitch sw) { return await _connection.QueryAsync<PKMember>( @@ -215,5 +221,76 @@ namespace PluralKit { { await _connection.ExecuteAsync("delete from switches where id = @Id", new {Id = sw.Id}); } + + public struct SwitchListEntry + { + public ICollection<PKMember> Members; + public Duration TimespanWithinRange; + } + + public async Task<IEnumerable<SwitchListEntry>> GetTruncatedSwitchList(PKSystem system, Instant periodStart, Instant periodEnd) + { + // TODO: only fetch the necessary switches here + // todo: this is in general not very efficient LOL + // returns switches in chronological (newest first) order + var switches = await GetSwitches(system); + + // we skip all switches that happened later than the range end, and taking all the ones that happened after the range start + // *BUT ALSO INCLUDING* the last switch *before* the range (that partially overlaps the range period) + var switchesInRange = switches.SkipWhile(sw => sw.Timestamp >= periodEnd).TakeWhileIncluding(sw => sw.Timestamp > periodStart).ToList(); + + // query DB for all members involved in any of the switches above and collect into a dictionary for future use + // this makes sure the return list has the same instances of PKMember throughout, which is important for the dictionary + // key used in GetPerMemberSwitchDuration below + var memberObjects = (await _connection.QueryAsync<PKMember>( + "select distinct members.* from members, switch_members where switch_members.switch = any(@Switches) and switch_members.member = members.id", // lol postgres specific `= any()` syntax + new {Switches = switchesInRange.Select(sw => sw.Id).ToList()})) + .ToDictionary(m => m.Id); + + + // we create the entry objects + var outList = new List<SwitchListEntry>(); + + // loop through every switch that *occurred* in-range and add it to the list + // end time is the switch *after*'s timestamp - we cheat and start it out at the range end so the first switch in-range "ends" there instead of the one after's start point + var endTime = periodEnd; + foreach (var switchInRange in switchesInRange) + { + // find the start time of the switch, but clamp it to the range (only applicable to the Last Switch Before Range we include in the TakeWhileIncluding call above) + var switchStartClamped = switchInRange.Timestamp; + if (switchStartClamped < periodStart) switchStartClamped = periodStart; + + var span = endTime - switchStartClamped; + outList.Add(new SwitchListEntry + { + Members = (await GetSwitchMemberIds(switchInRange)).Select(id => memberObjects[id]).ToList(), + TimespanWithinRange = span + }); + + // next switch's end is this switch's start + endTime = switchInRange.Timestamp; + } + + return outList; + } + + public async Task<IDictionary<PKMember, Duration>> GetPerMemberSwitchDuration(PKSystem system, Instant periodStart, + Instant periodEnd) + { + var dict = new Dictionary<PKMember, Duration>(); + + // Sum up all switch durations for each member + // switches with multiple members will result in the duration to add up to more than the actual period range + foreach (var sw in await GetTruncatedSwitchList(system, periodStart, periodEnd)) + { + foreach (var member in sw.Members) + { + if (!dict.ContainsKey(member)) dict.Add(member, sw.TimespanWithinRange); + else dict[member] += sw.TimespanWithinRange; + } + } + + return dict; + } } } \ No newline at end of file diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index 24871607..54241e57 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using System.Text.RegularExpressions; @@ -216,6 +217,17 @@ namespace PluralKit return null; } } + + public static IEnumerable<T> TakeWhileIncluding<T>(this IEnumerable<T> list, Func<T, bool> predicate) + { + // modified from https://stackoverflow.com/a/6817553 + foreach(var el in list) + { + yield return el; + if (!predicate(el)) + yield break; + } + } } public static class Emojis { @@ -236,10 +248,10 @@ namespace PluralKit // a smaller duration we may only bother with showing <x>h <x>m or <x>m <x>s public static IPattern<Duration> DurationFormat = new CompositePatternBuilder<Duration> { - {DurationPattern.CreateWithInvariantCulture("D'd' h'h'"), d => d.Days > 0}, - {DurationPattern.CreateWithInvariantCulture("H'h' m'm'"), d => d.Hours > 0}, + {DurationPattern.CreateWithInvariantCulture("s's'"), d => true}, {DurationPattern.CreateWithInvariantCulture("m'm' s's'"), d => d.Minutes > 0}, - {DurationPattern.CreateWithInvariantCulture("s's'"), d => true} + {DurationPattern.CreateWithInvariantCulture("H'h' m'm'"), d => d.Hours > 0}, + {DurationPattern.CreateWithInvariantCulture("D'd' h'h'"), d => d.Days > 0} }.Build(); public static IPattern<LocalDateTime> LocalDateTimeFormat = LocalDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss"); From b5c9793578c3d5304bd9abd69468949fcba6fd09 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 1 Jul 2019 00:55:41 +0200 Subject: [PATCH 058/103] Add config file loading --- PluralKit.Bot/Bot.cs | 3 +++ PluralKit.Core/PluralKit.Core.csproj | 1 + 2 files changed, 4 insertions(+) diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 164249ea..7c25970a 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -1,6 +1,7 @@ using System; using System.Data; using System.Diagnostics; +using System.IO; using System.Linq; using System.Reflection; using System.Threading; @@ -21,6 +22,8 @@ namespace PluralKit.Bot private IConfiguration _config; static void Main(string[] args) => new Initialize { _config = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("pluralkit.conf", true) .AddEnvironmentVariables() .AddCommandLine(args) .Build()}.MainAsync().GetAwaiter().GetResult(); diff --git a/PluralKit.Core/PluralKit.Core.csproj b/PluralKit.Core/PluralKit.Core.csproj index d772231a..88e25534 100644 --- a/PluralKit.Core/PluralKit.Core.csproj +++ b/PluralKit.Core/PluralKit.Core.csproj @@ -11,6 +11,7 @@ <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="2.2.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="2.2.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="2.2.4" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="2.2.0" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.2" /> <PackageReference Include="Npgsql" Version="4.0.6" /> <PackageReference Include="Npgsql.NodaTime" Version="4.0.6" /> From af746ccf8198364b737725d706f60032b79b5cc2 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 1 Jul 2019 00:55:47 +0200 Subject: [PATCH 059/103] Add example config file --- pluralkit.conf.example | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 pluralkit.conf.example diff --git a/pluralkit.conf.example b/pluralkit.conf.example new file mode 100644 index 00000000..e69de29b From 2e4d111242b2b450f8421241288eee32b65c3c72 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 1 Jul 2019 01:04:35 +0200 Subject: [PATCH 060/103] Update README for rewrite --- README.md | 27 +++++++++++++-------------- docker-compose.yml | 3 ++- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d22338e7..d94b6f81 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,21 @@ # PluralKit - PluralKit is a Discord bot meant for plural communities. It has features like message proxying through webhooks, switch tracking, system and member profiles, and more. -PluralKit has a Discord server for support and discussion: https://discord.gg/PczBt78 +**Do you just want to add PluralKit to your server? If so, you don't need any of this. Use the bot's invite link: https://discordapp.com/oauth2/authorize?client_id=466378653216014359&scope=bot&permissions=536995904** +PluralKit has a Discord server for support, feedback, and discussion: https://discord.gg/PczBt78 # Requirements -Running the bot requires Python (specifically version 3.6) and PostgreSQL. +Running the bot requires [.NET Core](https://dotnet.microsoft.com/download) (>=2.2) and a PostgreSQL database. # Configuration -Configuring the bot is done through a configuration file. An example of the configuration format can be seen in [`pluralkit.conf.example`](https://github.com/xSke/PluralKit/blob/master/pluralkit.conf.example). +Configuring the bot is done through a JSON configuration file. An example of the configuration format can be seen in [`pluralkit.conf.example`](https://github.com/xSke/PluralKit/blob/master/pluralkit.conf.example). +The configuration file needs to be placed in the bot's working directory (usually the repository root) and must be called `pluralkit.conf`. + +The configuration file is in JSON format (albeit with a `.conf` extension), and the following keys (using `.` to indicate a nested object level) are available: The following keys are available: -* `token`: the Discord bot token to connect with -* `database_uri`: the URI of the database to connect to (format: `postgres://username:password@hostname:port/database_name`) -* `log_channel` (optional): a Discord channel ID the bot will post exception tracebacks in (make this private!) - -The environment variables `TOKEN` and `DATABASE_URI` will override the configuration file values when present. +* `PluralKit.Database`: the URI of the database to connect to (in [ADO.NET Npgsql format](https://www.connectionstrings.com/npgsql/): `postgres://username:password@hostname:port/database_name`) +* `PluralKit.Bot.Token`: the Discord bot token to connect with # Running @@ -23,16 +23,15 @@ The environment variables `TOKEN` and `DATABASE_URI` will override the configura Running PluralKit is pretty easy with Docker. The repository contains a `docker-compose.yml` file ready to use. * Clone this repository: `git clone https://github.com/xSke/PluralKit` -* Create a `pluralkit.conf` file in the same directory as `docker-compose.yml` containing at least a `token` field +* Create a `pluralkit.conf` file in the same directory as `docker-compose.yml` containing at least a `PluralKit.Bot.Token` field * Build the bot: `docker-compose build` * Run the bot: `docker-compose up` ## Manually +* Install the .NET Core 2.2 SDK (see https://dotnet.microsoft.com/download) * Clone this repository: `git clone https://github.com/xSke/PluralKit` -* Create a virtualenv: `virtualenv --python=python3.6 venv` -* Install dependencies: `venv/bin/pip install -r requirements.txt` -* Run PluralKit with the config file: `venv/bin/python src/bot_main.py` - * The bot optionally takes a parameter describing the location of the configuration file, defaulting to `./pluralkit.conf`. +* Run the bot: `dotnet run --project PluralKit.Bot` + # License This project is under the Apache License, Version 2.0. It is available at the following link: https://www.apache.org/licenses/LICENSE-2.0 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ed2ca8d2..263ea55d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,9 @@ services: build: . entrypoint: ["dotnet", "run", "--project", "PluralKit.Bot"] environment: - - "PluralKit:Bot:Token" - "PluralKit:Database=Host=db;Username=postgres;Password=postgres;Database=postgres" + volumes: + - "./pluralkit.conf:/app/pluralkit.conf:ro" links: - db web: From 2dae4fbde053555f89e026c0645615b92a5bc88f Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 1 Jul 2019 01:16:44 +0200 Subject: [PATCH 061/103] Actually add the example config file --- pluralkit.conf.example | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pluralkit.conf.example b/pluralkit.conf.example index e69de29b..60ca0f30 100644 --- a/pluralkit.conf.example +++ b/pluralkit.conf.example @@ -0,0 +1,8 @@ +{ + "PluralKit": { + "Bot": { + "Token": "BOT_TOKEN_GOES_HERE" + }, + "Database": "Host=localhost;Port=5432;Username=myusername;Password=mypassword;Database=mydatabasename" + } +} \ No newline at end of file From 0a8e72b451d9ae328d7e7f84903b523e7ab04154 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 1 Jul 2019 01:21:56 +0200 Subject: [PATCH 062/103] Fix stray old connection string in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d94b6f81..6566df43 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The configuration file needs to be placed in the bot's working directory (usuall The configuration file is in JSON format (albeit with a `.conf` extension), and the following keys (using `.` to indicate a nested object level) are available: The following keys are available: -* `PluralKit.Database`: the URI of the database to connect to (in [ADO.NET Npgsql format](https://www.connectionstrings.com/npgsql/): `postgres://username:password@hostname:port/database_name`) +* `PluralKit.Database`: the URI of the database to connect to (in [ADO.NET Npgsql format](https://www.connectionstrings.com/npgsql/)) * `PluralKit.Bot.Token`: the Discord bot token to connect with # Running From ce999895c804891e49261df33b44df641beae022 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 1 Jul 2019 17:57:43 +0200 Subject: [PATCH 063/103] Add basic help command --- PluralKit.Bot/Commands/HelpCommands.cs | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 PluralKit.Bot/Commands/HelpCommands.cs diff --git a/PluralKit.Bot/Commands/HelpCommands.cs b/PluralKit.Bot/Commands/HelpCommands.cs new file mode 100644 index 00000000..a4aee6b7 --- /dev/null +++ b/PluralKit.Bot/Commands/HelpCommands.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Discord; +using Discord.Commands; + +namespace PluralKit.Bot.Commands +{ + [Group("help")] + public class HelpCommands: ModuleBase<PKCommandContext> + { + [Command] + public async Task HelpRoot() + { + await Context.Channel.SendMessageAsync(embed: new EmbedBuilder() + .WithTitle("PluralKit") + .WithDescription("PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.") + .AddField("What is this for? What are systems?", "This bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks. This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account.") + .AddField("Why are people's names saying [BOT] next to them?", "These people are not actually bots, this is just a Discord limitation. Type `pk;help proxy` for an in-depth explanation.") + .AddField("How do I get started?", "To get started using PluralKit, try running the following commands (of course replacing the relevant names with your own):\n**1**. `pk;system new` - Create a system (if you haven't already)\n**2**. `pk;member add John` - Add a new member to your system\n**3**. `pk;member John proxy [text]` - Set up [square brackets] as proxy tags\n**4**. You're done! You can now type [a message in brackets] and it'll be proxied appropriately.\n**5**. Optionally, you may set an avatar from the URL of an image with `pk;member John avatar [link to image]`, or from a file by typing `pk;member John avatar` and sending the message with an attached image.\n\nType `pk;help member` for more information.") + .AddField("Useful tips", $"React with {Emojis.Error} on a proxied message to delete it (only if you sent it!)\nReact with {Emojis.RedQuestion} on a proxied message to look up information about it (like who sent it)\nType **`pk;invite`** to get a link to invite this bot to your own server!") + .AddField("More information", "For a full list of commands, type `pk;commands`.\nFor a more in-depth explanation of message proxying, type `pk;help proxy`.\nIf you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there.") + .AddField("Support server", "We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78") + .WithFooter("By @Ske#6201 | GitHub: https://github.com/xSke/PluralKit/") + .WithColor(Color.Blue) + .Build()); + } + } +} \ No newline at end of file From f46fbdf7d4d55f47a924ceaaa19cd6ca838d6e4f Mon Sep 17 00:00:00 2001 From: Biquet <totalamor@hotmail.fr> Date: Tue, 2 Jul 2019 15:55:31 +0200 Subject: [PATCH 064/103] Fix missing RedQuestion emoji (#110) --- PluralKit.Core/Utils.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index 54241e57..3fe1e014 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -236,6 +236,7 @@ namespace PluralKit public static readonly string Error = "\u274C"; public static readonly string Note = "\u2757"; public static readonly string ThumbsUp = "\U0001f44d"; + public static readonly string RedQuestion = "\u2753"; } public static class Formats @@ -257,4 +258,4 @@ namespace PluralKit public static IPattern<LocalDateTime> LocalDateTimeFormat = LocalDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss"); public static IPattern<ZonedDateTime> ZonedDateTimeFormat = ZonedDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss x", DateTimeZoneProviders.Tzdb); } -} \ No newline at end of file +} From f4b060757245f25f4ffe8d566da2cfbfe4b9b291 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 3 Jul 2019 10:25:17 +0200 Subject: [PATCH 065/103] Add basic documentation site --- .gitignore | 2 - PluralKit.Core/Utils.cs | 2 +- docs/.gitignore | 7 + docs/1-user-guide.md | 326 ++++++++++++++++++++++++++++++++++++ docs/2-command-list.md | 13 ++ docs/3-api-documentation.md | 5 + docs/4-privacy-policy.md | 5 + docs/Gemfile | 2 + docs/_config.yml | 10 ++ docs/index.md | 8 + 10 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 docs/.gitignore create mode 100644 docs/1-user-guide.md create mode 100644 docs/2-command-list.md create mode 100644 docs/3-api-documentation.md create mode 100644 docs/4-privacy-policy.md create mode 100644 docs/Gemfile create mode 100644 docs/_config.yml create mode 100644 docs/index.md diff --git a/.gitignore b/.gitignore index 5f36b626..5c454b95 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,6 @@ obj/ .env .vscode/ .idea/ -venv/ -*.pyc pluralkit.conf pluralkit.*.conf \ No newline at end of file diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index 3fe1e014..51ea87af 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -86,7 +86,7 @@ namespace PluralKit "MMMM d", // January 1 "MM-dd", // 01-01 "MM dd", // 01 01 - "MM/dd" // 01-01 + "MM/dd" // 01/01 }); // Giving a template value so year will be parsed as 0001 if not present diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..a2ef63e8 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,7 @@ +_site +.sass-cache +.jekyll-metadata + +.bundle +vendor +Gemfile.lock \ No newline at end of file diff --git a/docs/1-user-guide.md b/docs/1-user-guide.md new file mode 100644 index 00000000..06edbccd --- /dev/null +++ b/docs/1-user-guide.md @@ -0,0 +1,326 @@ +--- +layout: default +title: User Guide +permalink: /guide +--- + +# User Guide +{: .no_toc } + +## Table of Contents +{: .no_toc .text-delta } + +1. TOC +{:toc} + +## Adding the bot to your server +If you want to use PluralKit on a Discord server, you must first *add* it to the server in question. For this, you'll need the *Manage Server* permission on there. + +Use this link to add the bot to your server: + +[https://discordapp.com/oauth2/authorize?client_id=466378653216014359&scope=bot&permissions=536995904](https://discordapp.com/oauth2/authorize?client_id=466378653216014359&scope=bot&permissions=536995904) + +Once you go through the wizard, the bot account will automatically join the server you've chosen. Please ensure the bot has the *Read Messages*, *Send Messages*, *Manage Messages*, *Attach Files* and *Manage Webhooks* permission in the channels you want it to work in. + +## System management +In order to do most things with the PluralKit bot, you'll need to have a system registered with it. A *system* is a collection of *system members* that may be used by one or more *Discord accounts*. + +### Creating a system +If you do not already have a system registered, use the following command to create one: + + pk;system new + +Optionally, you can attach a *system name*, which will be displayed in various information cards, like so: + + pk;system new My System Name + +### Viewing information about a system +To view information about your own system, simply type: + + pk;system + +To view information about *a different* system, there are a number of ways to do so. You can either look up a system by @mention, by account ID, or by system ID. For example: + + pk;system @Craig#5432 + pk;system 466378653216014359 + pk;system abcde + +### System description +If you'd like to add a small blurb to your system information card, you can add a *system description*. To do so, use the `pk;system description` command, as follows: + + pk;system description This is my system description. Hello. Lorem ipsum dolor sit amet. + +There's a 1000 character length limit on your system description - which is quite a lot! + +If you'd like to remove your system description, just type `pk;system description` without any further parameters. + +### System avatars +If you'd like your system to have an associated "system avatar", displayed on your system information card, you can add a system avatar. To do so, use the `pk;system avatar` command. You can either supply it with an direct URL to an image, or attach an image directly. For example. + + pk;system avatar http://placebeard.it/512.jpg + pk;system avatar [with attached image] + +To clear your avatar, simply type `pk;system avatar` with no attachment or link. + +### System tags +Your system tag is a little snippet of text that'll be added to the end of all proxied messages. +For example, if you want to proxy a member named `James`, and your system tag is `| The Boys`, the final name displayed +will be `James | The Boys`. This is useful for identifying your system in-chat, and some servers may require you use +a system tag. Note that emojis *are* supported! To set one, use the `pk;system tag` command, like so: + + pk;system tag | The Boys + pk;system tag (Test System) + pk;system tag 🛰️ + +If you want to remove your system tag, just type `pk;system tag` with no extra parameters. + +**NB:** When proxying, the *total webhook username* must be 32 characters or below. As such, if you have a long system name, your tag might be enough +to bump it over that limit. PluralKit will warn you if you have a member name/tag combination that will bring the combined username above the limit. +You can either make the member name or the system tag shorter to solve this. + +### Adding or removing Discord accounts to the system +If you have multiple Discord accounts you want to use the same system on, you don't need to create multiple systems. +Instead, you can *link* the same system to multiple accounts. + +Let's assume the account you want to link to is called @Craig#5432. You'd link it to your *current* system by running this command from an account that already has access to the system: + + pk;link @Craig#5432 + +PluralKit will require you to confirm the link by clicking on a reaction *from the other account*. + +If you now want to unlink that account, use the following command: + + pk;unlink @Craig#5432 + +You may not remove the only account linked to a system, as that would leave the system inaccessible. Both the `pk;link` and `pk;unlink` commands work with account IDs instead of @mentions, too. + +### Setting a system time zone +PluralKit defaults to showing dates and times in [UTC](https://en.wikipedia.org/wiki/Coordinated_Universal_Time). +If you'd like, you can set a *system time zone*, and as such every date and time displayed in PluralKit +(on behalf of your system) will be in the system time zone. To do so, use the `pk;system timezone` command, like so: + + pk;system timezone Europe/Copenhagen + pk;system timezone America/New_York + pk;system timezone DE + pk;system timezone 🇬🇧 + +You can specify time zones in various ways. In regions with large amounts of time zones (eg. the Americas, Europe, etc), +specifying an exact time zone code is the best way. To get your local time zone code, visit [this site](https://xske.github.io/tz). +You can see the full list [here, on Wikipedia](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (see the column *TZ database name*). +You can also search by country code, either by giving the two-character [*ISO-3166-1 alpha-2* country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements) (eg. `GB` or `DE`), or just by a country flag emoji. + +To clear a time zone, type `pk;system timezone` without any parameters. + +### Deleting a system +If you want to delete your own system, simply use the command: + + pk;system delete + +You will need to verify by typing the system's ID when the bot prompts you to - to prevent accidental deletions. + +## Member management + +In order to do most things related to PluralKit, you need to work with *system members*. + +Most member commands follow the format of `pk;member MemberName verb Parameter`. Note that if a member's name has multiple words, you'll need to enclose it in "double quotes" throughout the commands below. + +### Creating a member +You can't do much with PluralKit without having registered members with your system, but doing so is quite simple - just use the `pk;member new` command followed by the member's name, like so: + + pk;member new John + pk;member new Craig Smith + +As the one exception to the rule above, if the name consists of multiple words you must *not* enclose it in double quotes. + +### Looking up member info +To view information about a member, there are a couple ways to do it. Either you can address a member by their name (if they're in your own system), or by their 5-character *member ID*, like so: + + pk;member John + pk;member qazws + +Member IDs are the only way to address a member in another system, and you can find it in various places - for example the system's member list, or on a message info card gotten by reacting to messages with a question mark. + +### Listing system members +To list all the members in a system, use the `pk;system list` command. This will show a paginated list of all member names in the system. You can either run it on your own system, or another - like so: + + pk;system list + pk;system @Craig#5432 list + pk;system qazws list + +If you want a more detailed list, with fields such as pronouns and description, add the word `full` to the end of the command, like so: + + pk;system list full + pk;system @Craig#5432 list full + pk;system qazws list full + +### Member renaming +If you want to change the name of a member, you can use the `pk;member rename` command, like so: + + pk;member John rename Joanne + pk;member "Craig Smith" rename "Craig Johnson" + +### Member description +In the same way as a system can have a description, so can a member. You can set a description using the `pk;member description` command, like so: + + pk;member John description John is a very cool person, and you should give him hugs. + +As with system descriptions, the member description has a 1000 character length limit. +To clear a member description, use the command with no additional parameters (eg. `pk;member John description`). + +### Member color +A system member can have an associated color value. +This color is *not* displayed as a name color on proxied messages due to a Discord limitation, +but it's shown in member cards, and it can be used in third-party apps, too. +To set a member color, use the `pk;member color` command with [a hexadecimal color code](https://htmlcolorcodes.com/), like so: + + pk;member John color #ff0000 + pk;member John color #87ceeb + +To clear a member color, use the command with no color code argument (eg. `pk;member John color`). + +### Member avatar +If you want your member to have an associated avatar to display on the member information card and on proxied messages, you can set the member avatar. To do so, use the `pk;member avatar` command. You can either supply it with an direct URL to an image, or attach an image directly. For example. + + pk;member John avatar http://placebeard.it/512.jpg + pk;member "Craig Johnson" avatar [with attached image] + +To clear your avatar, simply use the command with no attachment or link (eg. `pk;member John avatar`). + +### Member pronouns +If you want to list a member's preferred pronouns, you can use the pronouns field on a member profile. This is a free text field, so you can put whatever you'd like in there (with a 100 character limit), like so: + + pk;member John pronouns he/him + pk;member "Craig Johnson" pronouns anything goes, really + pk;member Skyler pronouns xe/xir or they/them + +To remove a member's pronouns, use the command with no pronoun argument (eg. `pk;member John pronouns`). + +### Member birthdate +If you want to list a member's birthdate on their information card, you can set their birthdate through PluralKit using the `pk;member birthdate` command. Please use [ISO-8601 format](https://xkcd.com/1179/) (`YYYY-MM-DD`) for best results, like so: + + pk;member John birthdate 1996-07-24 + pk;member "Craig Johnson" birthdate 2004-02-28 + +You can also set a birthdate without a year, either in `MM-DD` format or `Month Day` format, like so: + + pk;member John birthdate 07-24 + pk;member "Craig Johnson" birthdate Feb 28 + +To clear a birthdate, use the command with no birthday argument (eg. `pk;member John birthdate`). + +### Deleting members +If you want to delete a member, use the `pk;member delete` command, like so: + + pk;member John delete + +You'll need to confirm the deletion by replying with the member's ID when the bot asks you to - this is to avoid accidental deletion. + +## Proxying +Proxying is probably the most important part of PluralKit. This allows you to essentially speak "as" the member, +with the proper name and avatar displayed on the message. To do so, you must at least [have created a member](#creating-a-system). + +### Setting up proxy tags +You'll need to register a set of *proxy tags*, which are prefixes and/or suffixes you "enclose" the real message in, as a signal to PluralKit to indicate +which member to proxy as. Common proxy tags include `[square brackets]`, `{curly braces}` or `A:letter prefixes`. + +To set a proxy tag, use the `pk;member proxy` command on the member in question. You'll need to provide a "proxy example", containing the word `text`. +For example, if you want square brackets, the proxy example must be `[text]`. If you want a letter prefix, make it something like `A:text`. For example: + + pk;member John proxy [text] + pk;member "Craig Johnson" proxy {text} + pk;member John proxy J:text + +You can have any proxy tags you want, including one containing emojis. + +You can now type a message enclosed in your proxy tags, and it'll be deleted by PluralKit and reposted with the appropriate member name and avatar (if set). + +**NB:** If you want `<angle brackets>` as proxy tags, there is currently a bug where custom server emojis will (wrongly) +be interpreted as proxying with that member (see [issue #37](https://github.com/xSke/PluralKit/issues/37)). The current workaround is to use different proxy tags. + +### Querying message information +If you want information about a proxied message (eg. for moderation reasons), you can query the message for its sender account, system, member, etc. + +Either you can react to the message itself with the ❔ or ❓ emoji, which will DM you information about the message in question, +or you can use the `pk;message` command followed by [the message's ID](https://support.discordapp.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-). + +### Deleting messages +Since the messages will be posted by PluralKit's webhook, there's no way to delete the message as you would a normal user message. +To delete a PluralKit-proxied message, you can react to it with the ❌ emoji. Note that this only works if the message has +been sent from your own account. + +## Managing switches +PluralKit allows you to log member switches through the bot. +Essentially, this means you can mark one or more members as *the current fronter(s)* for the duration until the next switch. +You can then view the list of switches and fronters over time, and get statistics over which members have fronted for how long. + +### Logging switches +To log a switch, use the `pk;switch` command with one or more members. For example: + + pk;switch John + pk;switch "Craig Johnson" John + +Note that the order of members are preserved (this is useful for indicating who's "more" at front, if applicable). +If you want to specify a member with multiple words in their name, remember to encase the name in "double quotes". + +### Switching out +If you want to log a switch with *no* members, you can log a switch-out as follows: + + pk;switch out + +### Moving switches +If you want to log a switch that happened further back in time, you can log a switch and then *move* it back in time, using the `pk;switch move` command. +You can either specify a time either in relative terms (X days/hours/minutes/seconds ago) or in absolute terms (this date, at this time). +Absolute times will be interpreted in the [system time zone](#setting-a-system-time-zone). For example: + + pk;switch move 1h + pk;switch move 4d12h + pk;switch move 2 PM + pk;switch move May 8th 4:30 PM + +Note that you can't move a switch *before* the *previous switch*, to avoid breaking consistency. Here's a rough ASCII-art illustration of this: + + YOU CAN NOT YOU CAN + MOVE HERE MOVE HERE CURRENT SWITCH + v v START NOW + [===========================] | v v + [=== PREVIOUS SWITCH ===]| | + \________________________[=== CURRENT SWITCH ===] + + ----- TIME AXIS ----> + +### Delete switches +If you'd like to delete the most recent switch, use the `pk;switch delete` command. You'll need to confirm +the deletion by clicking a reaction. + +### Querying fronter +To see the current fronter in a system, use the `pk;system fronter` command. You can use this on your current system, or on other systems. For example: + + pk;system fronter + pk;system @Craig#5432 fronter + pk;system qazws fronter + +### Querying front history +To look at the front history of a system (currently limited to the last 10 switches). use the `pk;system fronthistory` command, for example: + + pk;system fronthistory + pk;system @Craig#5432 fronthistory + pk;system qazws fronthistory + +### Querying front percentage +To look at the per-member breakdown of the front over a given time period, use the `pk;system frontpercent` command. If you don't provide a time period, it'll default to 30 days. For example: + + pk;system frontpercent + pk;system @Craig#5432 frontpercent 7d + pk;system qazws frontpercent 100d12h + +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%. + +## Moderation commands + +### Log channel +If you want to log every proxied message to a separate channel for moderation purposes, you can use the `pk;log` command with the channel name. +This requires you to have the *Manage Server* permission on the server. For example: + + pk;log #proxy-log + +To disable logging, use the `pk;log` command with no channel name. \ No newline at end of file diff --git a/docs/2-command-list.md b/docs/2-command-list.md new file mode 100644 index 00000000..22a6a453 --- /dev/null +++ b/docs/2-command-list.md @@ -0,0 +1,13 @@ +--- +layout: default +title: Command List +permalink: /commands +--- + +# How to read this + +# Commands + +## System commands +## Member commands +## whatever \ No newline at end of file diff --git a/docs/3-api-documentation.md b/docs/3-api-documentation.md new file mode 100644 index 00000000..24d52899 --- /dev/null +++ b/docs/3-api-documentation.md @@ -0,0 +1,5 @@ +--- +layout: default +title: API documentation +permalink: /api +--- \ No newline at end of file diff --git a/docs/4-privacy-policy.md b/docs/4-privacy-policy.md new file mode 100644 index 00000000..e4fcbac1 --- /dev/null +++ b/docs/4-privacy-policy.md @@ -0,0 +1,5 @@ +--- +layout: default +title: Privacy Policy +permalink: /privacy +--- \ No newline at end of file diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 00000000..14a8769c --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,2 @@ +source "https://rubygems.org" +gem 'github-pages', group: :jekyll_plugins \ No newline at end of file diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 00000000..12e77ada --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,10 @@ +title: PluralKit +baseurl: "" +url: "https://pluralkit.me" +remote_theme: pmarsceill/just-the-docs + +search_enabled: true +aux_links: + "Add PluralKit to your server": + - "https://discordapp.com/oauth2/authorize?client_id=466378653216014359&scope=bot&permissions=536995904" +color_scheme: "dark" \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..f131658b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,8 @@ +--- +# Feel free to add content and custom Front Matter to this file. +# To modify the layout, see https://jekyllrb.com/docs/themes/#overriding-theme-defaults + +layout: home +--- + +OwO \ No newline at end of file From ab49ad7217a07ffd7342e3ecf01a1e0e9a80de5a Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 3 Jul 2019 11:44:57 +0200 Subject: [PATCH 066/103] Add command list and API documentation --- docs/1-user-guide.md | 38 ++++++++++++++++++++++++++++- docs/2-command-list.md | 46 +++++++++++++++++++++++++++++++++-- docs/3-api-documentation.md | 4 ++- docs/4-privacy-policy.md | 27 +++++++++++++++++++- docs/_config.yml | 3 +-- docs/_sass/custom/custom.scss | 1 + 6 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 docs/_sass/custom/custom.scss diff --git a/docs/1-user-guide.md b/docs/1-user-guide.md index 06edbccd..c9ca3963 100644 --- a/docs/1-user-guide.md +++ b/docs/1-user-guide.md @@ -323,4 +323,40 @@ This requires you to have the *Manage Server* permission on the server. For exam pk;log #proxy-log -To disable logging, use the `pk;log` command with no channel name. \ No newline at end of file +To disable logging, use the `pk;log` command with no channel name. + +## Importing and exporting data +If you're a user of another proxy bot (eg. Tupperbox), or you want to import a saved system backup, you can use the importing and exporting commands. + +### Importing from Tupperbox +If you're a user of the *other proxying bot* Tupperbox, you can import system and member information from there. This is a fairly simple process: + +1. Export your data from Tupperbox: +``` +tul!export +``` +2. Copy the URL for the data file (or download it) +3. Import your data into PluralKit: +``` +pk;import https://link/to/the/data/file.json +``` +*(alternatively, run `pk;import` by itself and attach the .json file)* + +Note that while Tupperbox supports features such as multiple proxies per member, per-member system tags, and member groups, PluralKit does not. +PluralKit will warn you when you're importing a Tupperbox file that makes use of such features, as they will not carry over. + +### Importing from PluralKit +If you have an exported file from PluralKit, you can import system, member and switch information from there like so: +1. Export your data from PluralKit: +``` +pk;export +``` +2. Copy the URL for the data file (or download it) +3. Import your data into PluralKit: +``` +pk;import https://link/to/the/data/file.json +``` +*(alternatively, run `pk;import` by itself and attach the .json file)* + +### Exporting your PluralKit data +To export all the data associated with your system, run the `pk;export` command. This will send you a JSON file containing your system, member, and switch information. \ No newline at end of file diff --git a/docs/2-command-list.md b/docs/2-command-list.md index 22a6a453..1251bedb 100644 --- a/docs/2-command-list.md +++ b/docs/2-command-list.md @@ -5,9 +5,51 @@ permalink: /commands --- # How to read this +Words in <angle brackets> are *required parameters*. Words in [square brackets] are *optional parameters*. Words with ellipses... indicate multiple repeating parameters. # Commands - ## System commands +- `pk;system [id]` - Shows information about a system. +- `pk;system new [name]` - Creates a new system registered to your account. +- `pk;system rename [new name]` - Changes the description of your system. +- `pk;system description [description]` - Changes the description of your system. +- `pk;system avatar [avatar url]` - Changes the avatar of your system. +- `pk;system tag [tag]` - Changes the system tag of your system. +- `pk;system timezone [location]` - Changes the time zone of your system. +- `pk;system delete` - Deletes your system. +- `pk;system [id] fronter` - Shows the current fronter of a system. +- `pk;system [id] fronthistory` - Shows the last 10 fronters of a system. +- `pk;system [id] frontpercent [timeframe]` - Shows the aggregated front history of a system within a given time frame. +- `pk;system [id] list` - Shows a paginated list of a system's members. +- `pk;system [id] list full` - Shows a paginated list of a system's members, with increased detail. +- `pk;link <account>` - Links this system to a different account. +- `pk;unlink [account]` - Unlinks an account from this system. ## Member commands -## whatever \ No newline at end of file +- `pk;member <name>` - Shows information about a member. +- `pk;member new <name>` - Creates a new system member. +- `pk;member <name> rename <new name>` - Changes the name of a member. +- `pk;member <name> description [description` - Changes the description of a member. +- `pk;member <name> avatar [avatar url]` - Changes the avatar of a member. +- `pk;member <name> proxy [tags]` - Changes the proxy tags of a member. +- `pk;member <name> pronouns [pronouns]` - Changes the pronouns of a member. +- `pk;member <name> color [color]` - Changes the color of a member. +- `pk;member <name> birthdate [birthdate]` - Changes the birthday of a member. +- `pk;member <name> delete` - Deletes a member. +## Switching commands +- `pk;switch [member...]` - Registers a switch with the given members. +- `pk;switch move <time>` - Moves the latest switch backwards in time. +- `pk;switch delete` - Deletes the latest switch. +- `pk;switch out` - Registers a 'switch-out' - a switch with no associated members. +## Utility +- `pk;log <channel>` - Sets the channel to log all proxied messages. +- `pk;message <message id>` - Looks up information about a proxied message by its message ID. +- `pk;invite` - Sends the bot invite link for PluralKit. +- `pk;import` - Imports a data file from PluralKit or Tupperbox. +- `pk;expoort` - Exports a data file containing your system information. +## API +- `pk;token` - DMs you a token for using the PluralKit API. +- `pk;token refresh` - Refreshes your API token and invalidates the old one. +## Help +- `pk;help` - Displays a basic help message describing how to use the bot. +- `pk;help proxy` - Directs you to [this page](/guide#proxying). +- `pk;commands` - Directs you to this page! \ No newline at end of file diff --git a/docs/3-api-documentation.md b/docs/3-api-documentation.md index 24d52899..fcee93bc 100644 --- a/docs/3-api-documentation.md +++ b/docs/3-api-documentation.md @@ -2,4 +2,6 @@ layout: default title: API documentation permalink: /api ---- \ No newline at end of file +--- + +# TODO \ No newline at end of file diff --git a/docs/4-privacy-policy.md b/docs/4-privacy-policy.md index e4fcbac1..a7210020 100644 --- a/docs/4-privacy-policy.md +++ b/docs/4-privacy-policy.md @@ -2,4 +2,29 @@ layout: default title: Privacy Policy permalink: /privacy ---- \ No newline at end of file +--- + +# Privacy Policy + +I'm not a lawyer. I don't want to write a 50 page document no one wants to (or can) read. In short: + +This is the data PluralKit collects indefinitely: + +* Information *you give the bot* (eg. system/member profiles, switch history, linked accounts, etc) +* Metadata about proxied messages (sender account ID, sender system/member, timestamp) +* Aggregate anonymous usage metrics (eg. gateway events received/second, messages proxied/second, commands executed/second) +* Nightly database backups of the above information + +This is the data PluralKit does *not* collect: +* Anything not listed above, including... +* Proxied message *contents* (they are fetched on-demand from the original message object when queried) +* Metadata about deleted messages, members, switches or systems +* Information added *and deleted* between nightly backups +* Information about messages that *aren't* proxied through PluralKit + +You can export your system information using the `pk;export` command. This does not include message metadata (as the file would be huge). If there's demand for a command to export that, [let me know on GitHub](https://github.com/xSke/PluralKit/issues). + +You can delete your information using `pk;delete`. This will delete all system information and associated members, switches, and messages. This will not delete your information from the database backups. Contact me if you want that wiped, too. + +The bot is [open-source](https://github.com/xSke/PluralKit). While I can't *prove* this is the code that's running on the production server... +it is, promise. \ No newline at end of file diff --git a/docs/_config.yml b/docs/_config.yml index 12e77ada..e32ac1c2 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -6,5 +6,4 @@ remote_theme: pmarsceill/just-the-docs search_enabled: true aux_links: "Add PluralKit to your server": - - "https://discordapp.com/oauth2/authorize?client_id=466378653216014359&scope=bot&permissions=536995904" -color_scheme: "dark" \ No newline at end of file + - "https://discordapp.com/oauth2/authorize?client_id=466378653216014359&scope=bot&permissions=536995904" \ No newline at end of file diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss new file mode 100644 index 00000000..b7048f83 --- /dev/null +++ b/docs/_sass/custom/custom.scss @@ -0,0 +1 @@ +$link-color: $blue-100; \ No newline at end of file From 4874879979b7455d8557164dd309007d1d5a63e5 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Tue, 9 Jul 2019 20:39:29 +0200 Subject: [PATCH 067/103] Add basic API, only with system endpoints --- PluralKit.API/Controllers/SystemController.cs | 156 ++++++++++++++++++ PluralKit.API/PluralKit.API.csproj | 17 ++ PluralKit.API/Program.cs | 26 +++ PluralKit.API/Properties/launchSettings.json | 30 ++++ PluralKit.API/Startup.cs | 72 ++++++++ PluralKit.API/TokenAuthService.cs | 31 ++++ PluralKit.API/app.config | 6 + PluralKit.API/appsettings.Development.json | 9 + PluralKit.API/appsettings.json | 8 + PluralKit.Bot/Bot.cs | 9 +- PluralKit.Core/DatabaseUtils.cs | 55 ------ PluralKit.Core/Models.cs | 54 +++--- PluralKit.Core/PluralKit.Core.csproj | 7 + PluralKit.Core/Schema.cs | 65 ++------ PluralKit.Core/Utils.cs | 73 ++++++++ PluralKit.Core/db_schema.sql | 69 ++++++++ PluralKit.Web/Startup.cs | 2 +- PluralKit.sln | 6 + 18 files changed, 550 insertions(+), 145 deletions(-) create mode 100644 PluralKit.API/Controllers/SystemController.cs create mode 100644 PluralKit.API/PluralKit.API.csproj create mode 100644 PluralKit.API/Program.cs create mode 100644 PluralKit.API/Properties/launchSettings.json create mode 100644 PluralKit.API/Startup.cs create mode 100644 PluralKit.API/TokenAuthService.cs create mode 100644 PluralKit.API/app.config create mode 100644 PluralKit.API/appsettings.Development.json create mode 100644 PluralKit.API/appsettings.json delete mode 100644 PluralKit.Core/DatabaseUtils.cs create mode 100644 PluralKit.Core/db_schema.sql diff --git a/PluralKit.API/Controllers/SystemController.cs b/PluralKit.API/Controllers/SystemController.cs new file mode 100644 index 00000000..73d1fb14 --- /dev/null +++ b/PluralKit.API/Controllers/SystemController.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using NodaTime; + +namespace PluralKit.API.Controllers +{ + public struct SwitchesReturn + { + [JsonProperty("timestamp")] public Instant Timestamp { get; set; } + [JsonProperty("members")] public IEnumerable<string> Members { get; set; } + } + + public struct FrontersReturn + { + [JsonProperty("timestamp")] public Instant Timestamp { get; set; } + [JsonProperty("members")] public IEnumerable<PKMember> Members { get; set; } + } + + public struct PostSwitchParams + { + public ICollection<string> Members { get; set; } + } + + [ApiController] + [Route("s")] + public class SystemController : ControllerBase + { + private SystemStore _systems; + private MemberStore _members; + private SwitchStore _switches; + private IDbConnection _conn; + private TokenAuthService _auth; + + public SystemController(SystemStore systems, MemberStore members, SwitchStore switches, IDbConnection conn, TokenAuthService auth) + { + _systems = systems; + _members = members; + _switches = switches; + _conn = conn; + _auth = auth; + } + + [HttpGet("{hid}")] + public async Task<ActionResult<PKSystem>> GetSystem(string hid) + { + var system = await _systems.GetByHid(hid); + if (system == null) return NotFound("System not found."); + return Ok(system); + } + + [HttpGet("{hid}/members")] + public async Task<ActionResult<IEnumerable<PKMember>>> GetMembers(string hid) + { + var system = await _systems.GetByHid(hid); + if (system == null) return NotFound("System not found."); + + var members = await _members.GetBySystem(system); + return Ok(members); + } + + [HttpGet("{hid}/switches")] + public async Task<ActionResult<IEnumerable<SwitchesReturn>>> GetSwitches(string hid, [FromQuery(Name = "before")] Instant? before) + { + if (before == default(Instant)) before = SystemClock.Instance.GetCurrentInstant(); + + var system = await _systems.GetByHid(hid); + if (system == null) return NotFound("System not found."); + + var res = await _conn.QueryAsync<SwitchesReturn>( + @"select *, array( + select members.hid from switch_members, members + where switch_members.switch = switches.id and members.id = switch_members.member + ) as members from switches + where switches.system = @System and switches.timestamp < @Before + order by switches.timestamp desc + limit 100;", new { System = system.Id, Before = before }); + return Ok(res); + } + + [HttpGet("{hid}/fronters")] + public async Task<ActionResult<FrontersReturn>> GetFronters(string hid) + { + var system = await _systems.GetByHid(hid); + if (system == null) return NotFound("System not found."); + + var sw = await _switches.GetLatestSwitch(system); + var members = await _switches.GetSwitchMembers(sw); + return Ok(new FrontersReturn + { + Timestamp = sw.Timestamp, + Members = members + }); + } + + [HttpPatch] + public async Task<ActionResult<PKSystem>> EditSystem([FromBody] PKSystem newSystem) + { + if (_auth.CurrentSystem == null) return Unauthorized("No token specified in Authorization header."); + var system = _auth.CurrentSystem; + + system.Name = newSystem.Name; + system.Description = newSystem.Description; + system.Tag = newSystem.Tag; + system.AvatarUrl = newSystem.AvatarUrl; + system.UiTz = newSystem.UiTz ?? "UTC"; + + await _systems.Save(system); + return Ok(system); + } + + [HttpPost("switches")] + public async Task<IActionResult> PostSwitch([FromBody] PostSwitchParams param) + { + if (_auth.CurrentSystem == null) return Unauthorized("No token specified in Authorization header."); + + if (param.Members.Distinct().Count() != param.Members.Count()) + return BadRequest("Duplicate members in member list."); + + // We get the current switch, if it exists + var latestSwitch = await _switches.GetLatestSwitch(_auth.CurrentSystem); + var latestSwitchMembers = await _switches.GetSwitchMembers(latestSwitch); + + // Bail if this switch is identical to the latest one + if (latestSwitchMembers.Select(m => m.Hid).SequenceEqual(param.Members)) + return BadRequest("New members identical to existing fronters."); + + // Resolve member objects for all given IDs + var membersList = (await _conn.QueryAsync<PKMember>("select * from members where hid = any(@Hids)", new {Hids = param.Members})).ToList(); + foreach (var member in membersList) + if (member.System != _auth.CurrentSystem.Id) + return BadRequest($"Cannot switch to member '{member.Hid}' not in system."); + + // membersList is in DB order, and we want it in actual input order + // so we go through a dict and map the original input appropriately + var membersDict = membersList.ToDictionary(m => m.Hid); + + var membersInOrder = new List<PKMember>(); + // We do this without .Select() since we want to have the early return bail if it doesn't find the member + foreach (var givenMemberId in param.Members) + { + if (!membersDict.TryGetValue(givenMemberId, out var member)) return BadRequest($"Member '{givenMemberId}' not found."); + membersInOrder.Add(member); + } + + // Finally, log the switch (yay!) + await _switches.RegisterSwitch(_auth.CurrentSystem, membersInOrder); + return NoContent(); + } + } +} \ No newline at end of file diff --git a/PluralKit.API/PluralKit.API.csproj b/PluralKit.API/PluralKit.API.csproj new file mode 100644 index 00000000..382fd144 --- /dev/null +++ b/PluralKit.API/PluralKit.API.csproj @@ -0,0 +1,17 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>netcoreapp2.2</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" /> + <PackageReference Include="Microsoft.AspNetCore.HttpsPolicy" Version="2.2.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\PluralKit.Core\PluralKit.Core.csproj" /> + </ItemGroup> + +</Project> diff --git a/PluralKit.API/Program.cs b/PluralKit.API/Program.cs new file mode 100644 index 00000000..880fba30 --- /dev/null +++ b/PluralKit.API/Program.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace PluralKit.API +{ + public class Program + { + public static void Main(string[] args) + { + InitUtils.Init(); + CreateWebHostBuilder(args).Build().Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + WebHost.CreateDefaultBuilder(args) + .UseConfiguration(InitUtils.BuildConfiguration(args).Build()) + .UseStartup<Startup>(); + } +} \ No newline at end of file diff --git a/PluralKit.API/Properties/launchSettings.json b/PluralKit.API/Properties/launchSettings.json new file mode 100644 index 00000000..ac364798 --- /dev/null +++ b/PluralKit.API/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:48228", + "sslPort": 44372 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "api/values", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "PluralKit.API": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/values", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/PluralKit.API/Startup.cs b/PluralKit.API/Startup.cs new file mode 100644 index 00000000..077bde80 --- /dev/null +++ b/PluralKit.API/Startup.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpsPolicy; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NodaTime; +using NodaTime.Serialization.JsonNet; +using Npgsql; + +namespace PluralKit.API +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddMvc(opts => { }) + .SetCompatibilityVersion(CompatibilityVersion.Version_2_2) + .AddJsonOptions(opts => { opts.SerializerSettings.BuildSerializerSettings(); }); + + services + .AddTransient<SystemStore>() + .AddTransient<MemberStore>() + .AddTransient<SwitchStore>() + .AddTransient<MessageStore>() + + .AddScoped<TokenAuthService>() + + .AddTransient(_ => Configuration.GetSection("PluralKit").Get<CoreConfig>() ?? new CoreConfig()) + .AddScoped<IDbConnection>(svc => + { + var conn = new NpgsqlConnection(svc.GetRequiredService<CoreConfig>().Database); + conn.Open(); + return conn; + }); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + else + { + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + //app.UseHsts(); + } + + app.UseHttpsRedirection(); + app.UseMiddleware<TokenAuthService>(); + app.UseMvc(); + } + } +} \ No newline at end of file diff --git a/PluralKit.API/TokenAuthService.cs b/PluralKit.API/TokenAuthService.cs new file mode 100644 index 00000000..7bfda4ce --- /dev/null +++ b/PluralKit.API/TokenAuthService.cs @@ -0,0 +1,31 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace PluralKit.API +{ + public class TokenAuthService: IMiddleware + { + public PKSystem CurrentSystem { get; set; } + + private SystemStore _systems; + + public TokenAuthService(SystemStore systems) + { + _systems = systems; + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + var token = context.Request.Headers["Authorization"].FirstOrDefault(); + if (token != null) + { + CurrentSystem = await _systems.GetByToken(token); + } + + await next.Invoke(context); + CurrentSystem = null; + } + } +} \ No newline at end of file diff --git a/PluralKit.API/app.config b/PluralKit.API/app.config new file mode 100644 index 00000000..a6ad8283 --- /dev/null +++ b/PluralKit.API/app.config @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8" ?> +<configuration> + <runtime> + <gcServer enabled="true"/> + </runtime> +</configuration> \ No newline at end of file diff --git a/PluralKit.API/appsettings.Development.json b/PluralKit.API/appsettings.Development.json new file mode 100644 index 00000000..e203e940 --- /dev/null +++ b/PluralKit.API/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/PluralKit.API/appsettings.json b/PluralKit.API/appsettings.json new file mode 100644 index 00000000..def9159a --- /dev/null +++ b/PluralKit.API/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 7c25970a..eb3cfcb3 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -21,18 +21,13 @@ namespace PluralKit.Bot { private IConfiguration _config; - static void Main(string[] args) => new Initialize { _config = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("pluralkit.conf", true) - .AddEnvironmentVariables() - .AddCommandLine(args) - .Build()}.MainAsync().GetAwaiter().GetResult(); + static void Main(string[] args) => new Initialize { _config = InitUtils.BuildConfiguration(args).Build()}.MainAsync().GetAwaiter().GetResult(); private async Task MainAsync() { Console.WriteLine("Starting PluralKit..."); - DatabaseUtils.Init(); + InitUtils.Init(); using (var services = BuildServiceProvider()) { diff --git a/PluralKit.Core/DatabaseUtils.cs b/PluralKit.Core/DatabaseUtils.cs deleted file mode 100644 index 06bb76a9..00000000 --- a/PluralKit.Core/DatabaseUtils.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Data; -using Dapper; -using NodaTime; -using Npgsql; - -namespace PluralKit -{ - public static class DatabaseUtils - { - public static void Init() - { - // Dapper by default tries to pass ulongs to Npgsql, which rejects them since PostgreSQL technically - // doesn't support unsigned types on its own. - // Instead we add a custom mapper to encode them as signed integers instead, converting them back and forth. - SqlMapper.RemoveTypeMap(typeof(ulong)); - SqlMapper.AddTypeHandler<ulong>(new UlongEncodeAsLongHandler()); - Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; - - // Also, use NodaTime. it's good. - NpgsqlConnection.GlobalTypeMapper.UseNodaTime(); - // With the thing we add above, Npgsql already handles NodaTime integration - // This makes Dapper confused since it thinks it has to convert it anyway and doesn't understand the types - // So we add a custom type handler that literally just passes the type through to Npgsql - SqlMapper.AddTypeHandler(new PassthroughTypeHandler<Instant>()); - SqlMapper.AddTypeHandler(new PassthroughTypeHandler<LocalDate>()); - } - - 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 PassthroughTypeHandler<T> : SqlMapper.TypeHandler<T> - { - public override void SetValue(IDbDataParameter parameter, T value) - { - parameter.Value = value; - } - - public override T Parse(object value) - { - return (T) value; - } - } - } -} \ No newline at end of file diff --git a/PluralKit.Core/Models.cs b/PluralKit.Core/Models.cs index b947d1ce..347878df 100644 --- a/PluralKit.Core/Models.cs +++ b/PluralKit.Core/Models.cs @@ -1,4 +1,5 @@ using Dapper.Contrib.Extensions; +using Newtonsoft.Json; using NodaTime; using NodaTime.Text; @@ -6,39 +7,38 @@ namespace PluralKit { public class PKSystem { - [Key] - public int Id { get; set; } - public string Hid { get; set; } - public string Name { get; set; } - public string Description { get; set; } - public string Tag { get; set; } - public string AvatarUrl { get; set; } - public string Token { get; set; } - public Instant Created { get; set; } - public string UiTz { get; set; } + [Key] [JsonIgnore] public int Id { get; set; } + [JsonProperty("id")] public string Hid { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("description")] public string Description { get; set; } + [JsonProperty("tag")] public string Tag { get; set; } + [JsonProperty("avatar_url")] public string AvatarUrl { get; set; } + [JsonIgnore] public string Token { get; set; } + [JsonProperty("created")] public Instant Created { get; set; } + [JsonProperty("tz")] public string UiTz { get; set; } - public int MaxMemberNameLength => Tag != null ? 32 - Tag.Length - 1 : 32; + [JsonIgnore] public int MaxMemberNameLength => Tag != null ? 32 - Tag.Length - 1 : 32; - public DateTimeZone Zone => DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz); + [JsonIgnore] public DateTimeZone Zone => DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz); } public class PKMember { - public int Id { get; set; } - public string Hid { get; set; } - public int System { get; set; } - public string Color { get; set; } - public string AvatarUrl { get; set; } - public string Name { get; set; } - public LocalDate? Birthday { get; set; } - public string Pronouns { get; set; } - public string Description { get; set; } - public string Prefix { get; set; } - public string Suffix { get; set; } - public Instant Created { get; set; } + [JsonIgnore] public int Id { get; set; } + [JsonProperty("id")] public string Hid { get; set; } + [JsonIgnore] public int System { get; set; } + [JsonProperty("color")] public string Color { get; set; } + [JsonProperty("avatar_url")] public string AvatarUrl { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("birthday")] public LocalDate? Birthday { get; set; } + [JsonProperty("pronouns")] public string Pronouns { get; set; } + [JsonProperty("description")] public string Description { get; set; } + [JsonProperty("prefix")] public string Prefix { get; set; } + [JsonProperty("suffix")] public string Suffix { get; set; } + [JsonProperty("created")] public Instant Created { get; set; } /// Returns a formatted string representing the member's birthday, taking into account that a year of "0001" is hidden - public string BirthdayString + [JsonIgnore] public string BirthdayString { get { @@ -50,8 +50,8 @@ namespace PluralKit } } - public bool HasProxyTags => Prefix != null || Suffix != null; - public string ProxyString => $"{Prefix ?? ""}text{Suffix ?? ""}"; + [JsonIgnore] public bool HasProxyTags => Prefix != null || Suffix != null; + [JsonIgnore] public string ProxyString => $"{Prefix ?? ""}text{Suffix ?? ""}"; } public class PKSwitch diff --git a/PluralKit.Core/PluralKit.Core.csproj b/PluralKit.Core/PluralKit.Core.csproj index 88e25534..0fd4f277 100644 --- a/PluralKit.Core/PluralKit.Core.csproj +++ b/PluralKit.Core/PluralKit.Core.csproj @@ -13,8 +13,15 @@ <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="2.2.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="2.2.0" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.2" /> + <PackageReference Include="NodaTime" Version="3.0.0-alpha01" /> + <PackageReference Include="NodaTime.Serialization.JsonNet" Version="2.2.0" /> <PackageReference Include="Npgsql" Version="4.0.6" /> <PackageReference Include="Npgsql.NodaTime" Version="4.0.6" /> </ItemGroup> + <ItemGroup> + <None Remove="db_schema.sql" /> + <EmbeddedResource Include="db_schema.sql" /> + </ItemGroup> + </Project> diff --git a/PluralKit.Core/Schema.cs b/PluralKit.Core/Schema.cs index 7dbc2f01..10c1f299 100644 --- a/PluralKit.Core/Schema.cs +++ b/PluralKit.Core/Schema.cs @@ -1,64 +1,19 @@ using System.Data; +using System.IO; using System.Threading.Tasks; using Dapper; namespace PluralKit { public static class Schema { - public static async Task CreateTables(IDbConnection connection) { - await connection.ExecuteAsync(@"create table if not exists systems ( - id serial primary key, - hid char(5) unique not null, - name text, - description text, - tag text, - avatar_url text, - token text, - created timestamp not null default (current_timestamp at time zone 'utc'), - ui_tz text not null default 'UTC' - )"); - await connection.ExecuteAsync(@"create table if not exists members ( - id serial primary key, - hid char(5) unique not null, - system serial not null references systems(id) on delete cascade, - color char(6), - avatar_url text, - name text not null, - birthday date, - pronouns text, - description text, - prefix text, - suffix text, - created timestamp not null default (current_timestamp at time zone 'utc') - )"); - await connection.ExecuteAsync(@"create table if not exists accounts ( - uid bigint primary key, - system serial not null references systems(id) on delete cascade - )"); - await connection.ExecuteAsync(@"create table if not exists messages ( - mid bigint primary key, - channel bigint not null, - member serial not null references members(id) on delete cascade, - sender bigint not null - )"); - await connection.ExecuteAsync(@"create table if not exists switches ( - id serial primary key, - system serial not null references systems(id) on delete cascade, - timestamp timestamp not null default (current_timestamp at time zone 'utc') - )"); - await connection.ExecuteAsync(@"create table if not exists switch_members ( - id serial primary key, - switch serial not null references switches(id) on delete cascade, - member serial not null references members(id) on delete cascade - )"); - await connection.ExecuteAsync(@"create table if not exists webhooks ( - channel bigint primary key, - webhook bigint not null, - token text not null - )"); - await connection.ExecuteAsync(@"create table if not exists servers ( - id bigint primary key, - log_channel bigint - )"); + public static async Task CreateTables(IDbConnection connection) + { + // Load the schema from disk (well, embedded resource) and execute the commands in there + using (var stream = typeof(Schema).Assembly.GetManifestResourceStream("PluralKit.Core.db_schema.sql")) + using (var reader = new StreamReader(stream)) + { + var result = await reader.ReadToEndAsync(); + await connection.ExecuteAsync(result); + } } } } \ No newline at end of file diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index 51ea87af..b73192dd 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -1,10 +1,17 @@ using System; using System.Collections.Generic; +using System.Data; +using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text.RegularExpressions; +using Dapper; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; using NodaTime; +using NodaTime.Serialization.JsonNet; using NodaTime.Text; +using Npgsql; namespace PluralKit @@ -258,4 +265,70 @@ namespace PluralKit public static IPattern<LocalDateTime> LocalDateTimeFormat = LocalDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss"); public static IPattern<ZonedDateTime> ZonedDateTimeFormat = ZonedDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss x", DateTimeZoneProviders.Tzdb); } + public static class InitUtils + { + public static IConfigurationBuilder BuildConfiguration(string[] args) => new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("pluralkit.conf", true) + .AddEnvironmentVariables() + .AddCommandLine(args); + + public static void Init() + { + InitDatabase(); + } + + private static void InitDatabase() + { + // Dapper by default tries to pass ulongs to Npgsql, which rejects them since PostgreSQL technically + // doesn't support unsigned types on its own. + // Instead we add a custom mapper to encode them as signed integers instead, converting them back and forth. + SqlMapper.RemoveTypeMap(typeof(ulong)); + SqlMapper.AddTypeHandler<ulong>(new UlongEncodeAsLongHandler()); + Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + + // Also, use NodaTime. it's good. + NpgsqlConnection.GlobalTypeMapper.UseNodaTime(); + // With the thing we add above, Npgsql already handles NodaTime integration + // This makes Dapper confused since it thinks it has to convert it anyway and doesn't understand the types + // So we add a custom type handler that literally just passes the type through to Npgsql + SqlMapper.AddTypeHandler(new PassthroughTypeHandler<Instant>()); + SqlMapper.AddTypeHandler(new PassthroughTypeHandler<LocalDate>()); + } + + public static JsonSerializerSettings BuildSerializerSettings() => new JsonSerializerSettings().BuildSerializerSettings(); + + public static JsonSerializerSettings BuildSerializerSettings(this JsonSerializerSettings settings) + { + settings.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); + return settings; + } + } + + public 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; + } + } + + public class PassthroughTypeHandler<T> : SqlMapper.TypeHandler<T> + { + public override void SetValue(IDbDataParameter parameter, T value) + { + parameter.Value = value; + } + + public override T Parse(object value) + { + return (T) value; + } + } } diff --git a/PluralKit.Core/db_schema.sql b/PluralKit.Core/db_schema.sql new file mode 100644 index 00000000..d755a12c --- /dev/null +++ b/PluralKit.Core/db_schema.sql @@ -0,0 +1,69 @@ +create table if not exists systems +( + id serial primary key, + hid char(5) unique not null, + name text, + description text, + tag text, + avatar_url text, + token text, + created timestamp not null default (current_timestamp at time zone 'utc'), + ui_tz text not null default 'UTC' +); + +create table if not exists members +( + id serial primary key, + hid char(5) unique not null, + system serial not null references systems (id) on delete cascade, + color char(6), + avatar_url text, + name text not null, + birthday date, + pronouns text, + description text, + prefix text, + suffix text, + created timestamp not null default (current_timestamp at time zone 'utc') +); + +create table if not exists accounts +( + uid bigint primary key, + system serial not null references systems (id) on delete cascade +); + +create table if not exists messages +( + mid bigint primary key, + channel bigint not null, + member serial not null references members (id) on delete cascade, + sender bigint not null +); + +create table if not exists switches +( + id serial primary key, + system serial not null references systems (id) on delete cascade, + timestamp timestamp not null default (current_timestamp at time zone 'utc') +); + +create table if not exists switch_members +( + id serial primary key, + switch serial not null references switches (id) on delete cascade, + member serial not null references members (id) on delete cascade +); + +create table if not exists webhooks +( + channel bigint primary key, + webhook bigint not null, + token text not null +); + +create table if not exists servers +( + id bigint primary key, + log_channel bigint +); \ No newline at end of file diff --git a/PluralKit.Web/Startup.cs b/PluralKit.Web/Startup.cs index df339377..050e1635 100644 --- a/PluralKit.Web/Startup.cs +++ b/PluralKit.Web/Startup.cs @@ -20,7 +20,7 @@ namespace PluralKit.Web // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - DatabaseUtils.Init(); + InitUtils.Init(); var config = Configuration.GetSection("PluralKit").Get<CoreConfig>(); diff --git a/PluralKit.sln b/PluralKit.sln index 18256e5c..1a9d4b73 100644 --- a/PluralKit.sln +++ b/PluralKit.sln @@ -6,6 +6,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.Core", "PluralKit EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.Web", "PluralKit.Web\PluralKit.Web.csproj", "{975F9DED-78D1-4742-8412-DF70BB381E92}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.API", "PluralKit.API\PluralKit.API.csproj", "{3420F8A9-125C-4F7F-A444-10DD16945754}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -24,5 +26,9 @@ Global {975F9DED-78D1-4742-8412-DF70BB381E92}.Debug|Any CPU.Build.0 = Debug|Any CPU {975F9DED-78D1-4742-8412-DF70BB381E92}.Release|Any CPU.ActiveCfg = Release|Any CPU {975F9DED-78D1-4742-8412-DF70BB381E92}.Release|Any CPU.Build.0 = Release|Any CPU + {3420F8A9-125C-4F7F-A444-10DD16945754}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3420F8A9-125C-4F7F-A444-10DD16945754}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3420F8A9-125C-4F7F-A444-10DD16945754}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3420F8A9-125C-4F7F-A444-10DD16945754}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal From 9a5152a74c6c972d1ba906e0407b1a8903bd277b Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 00:19:18 +0200 Subject: [PATCH 068/103] Add member routes to API --- PluralKit.API/Controllers/MemberController.cs | 66 +++++++++++++++++++ PluralKit.API/Controllers/SystemController.cs | 5 +- PluralKit.API/RequiresSystemAttribute.cs | 23 +++++++ PluralKit.Bot/Commands/MemberCommands.cs | 1 + PluralKit.Bot/Commands/SystemCommands.cs | 1 + PluralKit.Bot/Errors.cs | 1 + PluralKit.Bot/Utils.cs | 1 + {PluralKit.Bot => PluralKit.Core}/Limits.cs | 2 +- 8 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 PluralKit.API/Controllers/MemberController.cs create mode 100644 PluralKit.API/RequiresSystemAttribute.cs rename {PluralKit.Bot => PluralKit.Core}/Limits.cs (94%) diff --git a/PluralKit.API/Controllers/MemberController.cs b/PluralKit.API/Controllers/MemberController.cs new file mode 100644 index 00000000..e2d4b678 --- /dev/null +++ b/PluralKit.API/Controllers/MemberController.cs @@ -0,0 +1,66 @@ +using System.Data; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using PluralKit.Core; + +namespace PluralKit.API.Controllers +{ + [ApiController] + [Route("m")] + public class MemberController: ControllerBase + { + private MemberStore _members; + private IDbConnection _conn; + private TokenAuthService _auth; + + public MemberController(MemberStore members, IDbConnection conn, TokenAuthService auth) + { + _members = members; + _conn = conn; + _auth = auth; + } + + [HttpGet("{hid}")] + public async Task<ActionResult<PKMember>> GetMember(string hid) + { + var member = await _members.GetByHid(hid); + if (member == null) return NotFound("Member not found."); + + return Ok(member); + } + + [HttpPatch("{hid}")] + [RequiresSystem] + public async Task<ActionResult<PKMember>> PatchMember(string hid, [FromBody] PKMember newMember) + { + var member = await _members.GetByHid(hid); + if (member == null) return NotFound("Member not found."); + + if (member.System != _auth.CurrentSystem.Id) return Unauthorized($"Member '{hid}' is not part of your system."); + + // Explicit bounds checks + if (newMember.Name.Length > Limits.MaxMemberNameLength) + return BadRequest($"Member name too long ({newMember.Name.Length} > {Limits.MaxMemberNameLength}."); + if (newMember.Pronouns.Length > Limits.MaxPronounsLength) + return BadRequest($"Member pronouns too long ({newMember.Pronouns.Length} > {Limits.MaxPronounsLength}."); + if (newMember.Description.Length > Limits.MaxDescriptionLength) + return BadRequest($"Member descriptions too long ({newMember.Description.Length} > {Limits.MaxDescriptionLength}."); + + // Sanity bounds checks + if (newMember.AvatarUrl.Length > 1000 || newMember.Prefix.Length > 1000 || newMember.Suffix.Length > 1000) + return BadRequest(); + + member.Name = newMember.Name; + member.Color = newMember.Color; + member.AvatarUrl = newMember.AvatarUrl; + member.Birthday = newMember.Birthday; + member.Pronouns = newMember.Pronouns; + member.Description = newMember.Description; + member.Prefix = newMember.Prefix; + member.Suffix = newMember.Suffix; + await _members.Save(member); + + return Ok(); + } + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/SystemController.cs b/PluralKit.API/Controllers/SystemController.cs index 73d1fb14..3cedf147 100644 --- a/PluralKit.API/Controllers/SystemController.cs +++ b/PluralKit.API/Controllers/SystemController.cs @@ -99,9 +99,9 @@ namespace PluralKit.API.Controllers } [HttpPatch] + [RequiresSystem] public async Task<ActionResult<PKSystem>> EditSystem([FromBody] PKSystem newSystem) { - if (_auth.CurrentSystem == null) return Unauthorized("No token specified in Authorization header."); var system = _auth.CurrentSystem; system.Name = newSystem.Name; @@ -115,10 +115,9 @@ namespace PluralKit.API.Controllers } [HttpPost("switches")] + [RequiresSystem] public async Task<IActionResult> PostSwitch([FromBody] PostSwitchParams param) { - if (_auth.CurrentSystem == null) return Unauthorized("No token specified in Authorization header."); - if (param.Members.Distinct().Count() != param.Members.Count()) return BadRequest("Duplicate members in member list."); diff --git a/PluralKit.API/RequiresSystemAttribute.cs b/PluralKit.API/RequiresSystemAttribute.cs new file mode 100644 index 00000000..381cf29e --- /dev/null +++ b/PluralKit.API/RequiresSystemAttribute.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; + +namespace PluralKit.API +{ + public class RequiresSystemAttribute: ActionFilterAttribute + { + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var auth = context.HttpContext.RequestServices.GetRequiredService<TokenAuthService>(); + if (auth.CurrentSystem == null) + { + context.Result = new UnauthorizedObjectResult("Invalid or missing token in Authorization header."); + return; + } + + await base.OnActionExecutionAsync(context, next); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index 968c5fc2..a2ad4867 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Discord; using Discord.Commands; using NodaTime; +using PluralKit.Core; using Image = SixLabors.ImageSharp.Image; namespace PluralKit.Bot.Commands diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index 6a964324..477c02c3 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -9,6 +9,7 @@ using NodaTime; using NodaTime.Extensions; using NodaTime.Text; using NodaTime.TimeZones; +using PluralKit.Core; namespace PluralKit.Bot.Commands { diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 5cd76df9..6e346fe7 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net; using Humanizer; using NodaTime; +using PluralKit.Core; namespace PluralKit.Bot { public static class Errors { diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index e1fc6531..f55458ed 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -12,6 +12,7 @@ using Discord.Commands.Builders; using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; using NodaTime; +using PluralKit.Core; using Image = SixLabors.ImageSharp.Image; namespace PluralKit.Bot diff --git a/PluralKit.Bot/Limits.cs b/PluralKit.Core/Limits.cs similarity index 94% rename from PluralKit.Bot/Limits.cs rename to PluralKit.Core/Limits.cs index 3f7b3ec5..37f56ea7 100644 --- a/PluralKit.Bot/Limits.cs +++ b/PluralKit.Core/Limits.cs @@ -1,4 +1,4 @@ -namespace PluralKit.Bot { +namespace PluralKit.Core { public static class Limits { public static readonly int MaxSystemNameLength = 100; public static readonly int MaxSystemTagLength = 31; From 204404bd8d46b072f2942c2c18b01cde4c2cb4cf Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 00:21:00 +0200 Subject: [PATCH 069/103] Bounds check system details --- PluralKit.API/Controllers/SystemController.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/PluralKit.API/Controllers/SystemController.cs b/PluralKit.API/Controllers/SystemController.cs index 3cedf147..cfa36303 100644 --- a/PluralKit.API/Controllers/SystemController.cs +++ b/PluralKit.API/Controllers/SystemController.cs @@ -7,6 +7,7 @@ using Dapper; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using NodaTime; +using PluralKit.Core; namespace PluralKit.API.Controllers { @@ -104,6 +105,14 @@ namespace PluralKit.API.Controllers { var system = _auth.CurrentSystem; + // Bounds checks + if (newSystem.Name.Length > Limits.MaxSystemNameLength) + return BadRequest($"System name too long ({newSystem.Name.Length} > {Limits.MaxSystemNameLength}."); + if (newSystem.Tag.Length > Limits.MaxSystemTagLength) + return BadRequest($"System tag too long ({newSystem.Tag.Length} > {Limits.MaxSystemTagLength}."); + if (newSystem.Description.Length > Limits.MaxDescriptionLength) + return BadRequest($"System description too long ({newSystem.Description.Length} > {Limits.MaxDescriptionLength}."); + system.Name = newSystem.Name; system.Description = newSystem.Description; system.Tag = newSystem.Tag; From 802eeb8d397c43e6f60b8c8b30f43c5296c89ee4 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 00:23:41 +0200 Subject: [PATCH 070/103] Version API endpoints --- PluralKit.API/Controllers/MemberController.cs | 1 + PluralKit.API/Controllers/SystemController.cs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/PluralKit.API/Controllers/MemberController.cs b/PluralKit.API/Controllers/MemberController.cs index e2d4b678..ccdaea91 100644 --- a/PluralKit.API/Controllers/MemberController.cs +++ b/PluralKit.API/Controllers/MemberController.cs @@ -7,6 +7,7 @@ namespace PluralKit.API.Controllers { [ApiController] [Route("m")] + [Route("v1/m")] public class MemberController: ControllerBase { private MemberStore _members; diff --git a/PluralKit.API/Controllers/SystemController.cs b/PluralKit.API/Controllers/SystemController.cs index cfa36303..65cb575b 100644 --- a/PluralKit.API/Controllers/SystemController.cs +++ b/PluralKit.API/Controllers/SystemController.cs @@ -30,6 +30,7 @@ namespace PluralKit.API.Controllers [ApiController] [Route("s")] + [Route("v1/s")] public class SystemController : ControllerBase { private SystemStore _systems; @@ -68,7 +69,7 @@ namespace PluralKit.API.Controllers [HttpGet("{hid}/switches")] public async Task<ActionResult<IEnumerable<SwitchesReturn>>> GetSwitches(string hid, [FromQuery(Name = "before")] Instant? before) { - if (before == default(Instant)) before = SystemClock.Instance.GetCurrentInstant(); + if (before == null) before = SystemClock.Instance.GetCurrentInstant(); var system = await _systems.GetByHid(hid); if (system == null) return NotFound("System not found."); From de9554810a55e30db1df837009ab2ffba360c754 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 00:25:47 +0200 Subject: [PATCH 071/103] Disallow switching to member in another system --- PluralKit.Bot/Commands/SwitchCommands.cs | 7 ++++++- PluralKit.Bot/Errors.cs | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/SwitchCommands.cs b/PluralKit.Bot/Commands/SwitchCommands.cs index d313263a..cb630754 100644 --- a/PluralKit.Bot/Commands/SwitchCommands.cs +++ b/PluralKit.Bot/Commands/SwitchCommands.cs @@ -27,10 +27,15 @@ namespace PluralKit.Bot.Commands private async Task DoSwitchCommand(ICollection<PKMember> members) { + // Make sure all the members *are actually in the system* + // PKMember parameters won't let this happen if they resolve by name + // but they can if they resolve with ID + if (members.Any(m => m.System != Context.SenderSystem.Id)) throw Errors.SwitchMemberNotInSystem; + // Make sure there are no dupes in the list // We do this by checking if removing duplicate member IDs results in a list of different length if (members.Select(m => m.Id).Distinct().Count() != members.Count) throw Errors.DuplicateSwitchMembers; - + // Find the last switch and its members if applicable var lastSwitch = await Switches.GetLatestSwitch(Context.SenderSystem); if (lastSwitch != null) diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 6e346fe7..5ff000e5 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -49,6 +49,7 @@ namespace PluralKit.Bot { } public static PKError DuplicateSwitchMembers => new PKError("Duplicate members in member list."); + public static PKError SwitchMemberNotInSystem => new PKError("One or more switch members aren't in your own system."); public static PKError InvalidDateTime(string str) => new PKError($"Could not parse '{str}' as a valid date/time."); public static PKError SwitchTimeInFuture => new PKError("Can't move switch to a time in the future."); From 01c923c83261637e218569b03e19220bcdb83e03 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 08:55:24 +0200 Subject: [PATCH 072/103] Fix Dockerfile not including API subdirectory --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 5f7fe289..18653e98 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ FROM mcr.microsoft.com/dotnet/core/sdk:2.2-alpine WORKDIR /app +COPY PluralKit.API /app/PluralKit.API COPY PluralKit.Bot /app/PluralKit.Bot COPY PluralKit.Core /app/PluralKit.Core COPY PluralKit.Web /app/PluralKit.Web From 8dea58437dd3e4a3f09343cfaf4d846dd0488d26 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 09:35:37 +0200 Subject: [PATCH 073/103] Add short-hand group aliases --- PluralKit.Bot/Commands/MemberCommands.cs | 1 + PluralKit.Bot/Commands/SwitchCommands.cs | 1 + PluralKit.Bot/Commands/SystemCommands.cs | 1 + 3 files changed, 3 insertions(+) diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index a2ad4867..1b33ef63 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -12,6 +12,7 @@ using Image = SixLabors.ImageSharp.Image; namespace PluralKit.Bot.Commands { [Group("member")] + [Alias("m")] public class MemberCommands : ContextParameterModuleBase<PKMember> { public SystemStore Systems { get; set; } diff --git a/PluralKit.Bot/Commands/SwitchCommands.cs b/PluralKit.Bot/Commands/SwitchCommands.cs index cb630754..c2f34418 100644 --- a/PluralKit.Bot/Commands/SwitchCommands.cs +++ b/PluralKit.Bot/Commands/SwitchCommands.cs @@ -10,6 +10,7 @@ using NodaTime.TimeZones; namespace PluralKit.Bot.Commands { [Group("switch")] + [Alias("sw")] public class SwitchCommands: ModuleBase<PKCommandContext> { public SystemStore Systems { get; set; } diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index 477c02c3..775f771d 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -14,6 +14,7 @@ using PluralKit.Core; namespace PluralKit.Bot.Commands { [Group("system")] + [Alias("s")] public class SystemCommands : ContextParameterModuleBase<PKSystem> { public override string Prefix => "system"; From 9b488d1ab5cef82b14a3a8ab0acfbc626e1c3608 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 10:01:06 +0200 Subject: [PATCH 074/103] Add more lenient prefix parsing --- PluralKit.Bot/Bot.cs | 6 +++++- PluralKit.Bot/Commands/HelpCommands.cs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index eb3cfcb3..3d565c81 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -161,8 +161,12 @@ namespace PluralKit.Bot int argPos = 0; // Check if message starts with the command prefix - if (arg.HasStringPrefix("pk;", ref argPos) || arg.HasStringPrefix("pk!", ref argPos) || arg.HasMentionPrefix(_client.CurrentUser, ref argPos)) + if (arg.HasStringPrefix("pk;", ref argPos, StringComparison.OrdinalIgnoreCase) || arg.HasStringPrefix("pk!", ref argPos, StringComparison.OrdinalIgnoreCase) || arg.HasMentionPrefix(_client.CurrentUser, ref argPos)) { + // Essentially move the argPos pointer by however much whitespace is at the start of the post-argPos string + var trimStartLengthDiff = arg.Content.Substring(argPos).Length - arg.Content.Substring(argPos).TrimStart().Length; + argPos += trimStartLengthDiff; + // If it does, fetch the sender's system (because most commands need that) into the context, // and start command execution // Note system may be null if user has no system, hence `OrDefault` diff --git a/PluralKit.Bot/Commands/HelpCommands.cs b/PluralKit.Bot/Commands/HelpCommands.cs index a4aee6b7..a4c09edb 100644 --- a/PluralKit.Bot/Commands/HelpCommands.cs +++ b/PluralKit.Bot/Commands/HelpCommands.cs @@ -8,7 +8,7 @@ namespace PluralKit.Bot.Commands public class HelpCommands: ModuleBase<PKCommandContext> { [Command] - public async Task HelpRoot() + public async Task HelpRoot([Remainder] string _ignored) { await Context.Channel.SendMessageAsync(embed: new EmbedBuilder() .WithTitle("PluralKit") From 372a618cbebcba051f770b37860a81e412e40f8f Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 10:02:46 +0200 Subject: [PATCH 075/103] Bounds check color parameter validation --- PluralKit.Bot/Commands/MemberCommands.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index 1b33ef63..ba9568e6 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -116,7 +116,7 @@ namespace PluralKit.Bot.Commands if (color != null) { if (color.StartsWith("#")) color = color.Substring(1); - if (!Regex.IsMatch(color, "[0-9a-f]{6}")) throw Errors.InvalidColorError(color); + if (!Regex.IsMatch(color, "^[0-9a-f]{6}$")) throw Errors.InvalidColorError(color); } ContextEntity.Color = color; From 942b6206409e0e868f629bc13849a670f59073cf Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 10:03:26 +0200 Subject: [PATCH 076/103] Clarify color code format in error --- PluralKit.Bot/Errors.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 5ff000e5..ab93fd16 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -22,7 +22,7 @@ namespace PluralKit.Bot { public static PKError MemberNameTooLongError(int length) => new PKError($"Member name too long ({length}/{Limits.MaxMemberNameLength} characters)."); public static PKError MemberPronounsTooLongError(int length) => new PKError($"Member pronouns too long ({length}/{Limits.MaxMemberNameLength} characters)."); - public static PKError InvalidColorError(string color) => new PKError($"\"{color}\" is not a valid color. Color must be in hex format (eg. #ff0000)."); + public static PKError InvalidColorError(string color) => new PKError($"\"{color}\" is not a valid color. Color must be in 6-digit RGB hex format (eg. #ff0000)."); public static PKError BirthdayParseError(string birthday) => new PKError($"\"{birthday}\" could not be parsed as a valid date. Try a format like \"2016-12-24\" or \"May 3 1996\"."); public static PKError ProxyMustHaveText => new PKSyntaxError("Example proxy message must contain the string 'text'."); public static PKError ProxyMultipleText => new PKSyntaxError("Example proxy message must contain the string 'text' exactly once."); From 1bd5e7e3cae2df3d4a99013554855338413b9251 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 11:09:08 +0200 Subject: [PATCH 077/103] Clarify datetime format in switch move error --- PluralKit.Bot/Commands/HelpCommands.cs | 18 ++++++++++++------ PluralKit.Bot/Errors.cs | 2 +- PluralKit.Core/Utils.cs | 20 +++++++++++--------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/PluralKit.Bot/Commands/HelpCommands.cs b/PluralKit.Bot/Commands/HelpCommands.cs index a4c09edb..28ba18d9 100644 --- a/PluralKit.Bot/Commands/HelpCommands.cs +++ b/PluralKit.Bot/Commands/HelpCommands.cs @@ -4,24 +4,30 @@ using Discord.Commands; namespace PluralKit.Bot.Commands { - [Group("help")] public class HelpCommands: ModuleBase<PKCommandContext> { - [Command] + [Command("help")] public async Task HelpRoot([Remainder] string _ignored) { await Context.Channel.SendMessageAsync(embed: new EmbedBuilder() .WithTitle("PluralKit") .WithDescription("PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.") .AddField("What is this for? What are systems?", "This bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks. This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account.") - .AddField("Why are people's names saying [BOT] next to them?", "These people are not actually bots, this is just a Discord limitation. Type `pk;help proxy` for an in-depth explanation.") - .AddField("How do I get started?", "To get started using PluralKit, try running the following commands (of course replacing the relevant names with your own):\n**1**. `pk;system new` - Create a system (if you haven't already)\n**2**. `pk;member add John` - Add a new member to your system\n**3**. `pk;member John proxy [text]` - Set up [square brackets] as proxy tags\n**4**. You're done! You can now type [a message in brackets] and it'll be proxied appropriately.\n**5**. Optionally, you may set an avatar from the URL of an image with `pk;member John avatar [link to image]`, or from a file by typing `pk;member John avatar` and sending the message with an attached image.\n\nType `pk;help member` for more information.") + .AddField("Why are people's names saying [BOT] next to them?", "These people are not actually bots, this is just a Discord limitation. See [the documentation](https://pluralkit.me/guide#proxying) for an in-depth explanation.") + .AddField("How do I get started?", "To get started using PluralKit, try running the following commands (of course replacing the relevant names with your own):\n**1**. `pk;system new` - Create a system (if you haven't already)\n**2**. `pk;member add John` - Add a new member to your system\n**3**. `pk;member John proxy [text]` - Set up [square brackets] as proxy tags\n**4**. You're done! You can now type [a message in brackets] and it'll be proxied appropriately.\n**5**. Optionally, you may set an avatar from the URL of an image with `pk;member John avatar [link to image]`, or from a file by typing `pk;member John avatar` and sending the message with an attached image.\n\nSee [the documentation](https://pluralkit.me/guide#member-management) for more information.") .AddField("Useful tips", $"React with {Emojis.Error} on a proxied message to delete it (only if you sent it!)\nReact with {Emojis.RedQuestion} on a proxied message to look up information about it (like who sent it)\nType **`pk;invite`** to get a link to invite this bot to your own server!") - .AddField("More information", "For a full list of commands, type `pk;commands`.\nFor a more in-depth explanation of message proxying, type `pk;help proxy`.\nIf you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there.") + .AddField("More information", "For a full list of commands, see [the command list](https://pluralkit.me/commands).\nFor a more in-depth explanation of message proxying, see [the documentation](https://pluralkit.me/guide#proxying).\nIf you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there.") .AddField("Support server", "We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78") - .WithFooter("By @Ske#6201 | GitHub: https://github.com/xSke/PluralKit/") + .WithFooter("By @Ske#6201 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/") .WithColor(Color.Blue) .Build()); } + + [Command("commands")] + public async Task CommandList() + { + await Context.Channel.SendMessageAsync( + "The command list has been moved! See the website: https://pluralkit.me/commands"); + } } } \ No newline at end of file diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index ab93fd16..5ab98ffc 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -51,7 +51,7 @@ namespace PluralKit.Bot { public static PKError DuplicateSwitchMembers => new PKError("Duplicate members in member list."); public static PKError SwitchMemberNotInSystem => new PKError("One or more switch members aren't in your own system."); - public static PKError InvalidDateTime(string str) => new PKError($"Could not parse '{str}' as a valid date/time."); + public static PKError InvalidDateTime(string str) => new PKError($"Could not parse '{str}' as a valid date/time. Try using a syntax such as \"May 21, 12:30 PM\" or \"3d12h\" (ie. 3 days, 12 hours ago)."); public static PKError SwitchTimeInFuture => new PKError("Can't move switch to a time in the future."); public static PKError NoRegisteredSwitches => new PKError("There are no registered switches for this system."); diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index b73192dd..2e49620a 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -180,19 +180,21 @@ namespace PluralKit } } - // Then we try specific date+time combinations, both date first and time first + // Then we try specific date+time combinations, both date first and time first, with and without commas foreach (var timePattern in timePatterns) { foreach (var datePattern in datePatterns) { - var p1 = LocalDateTimePattern.CreateWithInvariantCulture($"{timePattern} {datePattern}").WithTemplateValue(midnight); - var res1 = p1.Parse(str); - if (res1.Success) return res1.Value.InZoneLeniently(zone); - - - var p2 = LocalDateTimePattern.CreateWithInvariantCulture($"{datePattern} {timePattern}").WithTemplateValue(midnight); - var res2 = p2.Parse(str); - if (res2.Success) return res2.Value.InZoneLeniently(zone); + foreach (var patternStr in new[] + { + $"{timePattern}, {datePattern}", $"{datePattern}, {timePattern}", + $"{timePattern} {datePattern}", $"{datePattern} {timePattern}" + }) + { + var pattern = LocalDateTimePattern.CreateWithInvariantCulture(patternStr).WithTemplateValue(midnight); + var res = pattern.Parse(str); + if (res.Success) return res.Value.InZoneLeniently(zone); + } } } From 305d8f220e7f5b17a373d0b884c6a7a81646212d Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 12:03:41 +0200 Subject: [PATCH 078/103] Add usage strings to all commands --- PluralKit.Bot/Commands/HelpCommands.cs | 2 ++ PluralKit.Bot/Commands/SystemCommands.cs | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/PluralKit.Bot/Commands/HelpCommands.cs b/PluralKit.Bot/Commands/HelpCommands.cs index 28ba18d9..183d2e6f 100644 --- a/PluralKit.Bot/Commands/HelpCommands.cs +++ b/PluralKit.Bot/Commands/HelpCommands.cs @@ -7,6 +7,7 @@ namespace PluralKit.Bot.Commands public class HelpCommands: ModuleBase<PKCommandContext> { [Command("help")] + [Remarks("help")] public async Task HelpRoot([Remainder] string _ignored) { await Context.Channel.SendMessageAsync(embed: new EmbedBuilder() @@ -24,6 +25,7 @@ namespace PluralKit.Bot.Commands } [Command("commands")] + [Remarks("commands")] public async Task CommandList() { await Context.Channel.SendMessageAsync( diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index 775f771d..b3326551 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -28,6 +28,7 @@ namespace PluralKit.Bot.Commands [Command] + [Remarks("system <name>")] public async Task Query(PKSystem system = null) { if (system == null) system = Context.SenderSystem; if (system == null) throw Errors.NoSystemError; @@ -151,6 +152,7 @@ namespace PluralKit.Bot.Commands } [Command("fronter")] + [Remarks("system [system] fronter")] public async Task SystemFronter() { var system = ContextEntity ?? Context.SenderSystem; @@ -163,6 +165,7 @@ namespace PluralKit.Bot.Commands } [Command("fronthistory")] + [Remarks("system [system] fronthistory")] public async Task SystemFrontHistory() { var system = ContextEntity ?? Context.SenderSystem; @@ -175,6 +178,7 @@ namespace PluralKit.Bot.Commands } [Command("frontpercent")] + [Remarks("system [system] frontpercent [duration]")] public async Task SystemFrontPercent(string durationStr = "30d") { var system = ContextEntity ?? Context.SenderSystem; From 53b33789010728b640afc42fc2410e3461334810 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 12:36:51 +0200 Subject: [PATCH 079/103] Fix importing by URL --- PluralKit.Bot/Commands/ImportExportCommands.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/ImportExportCommands.cs b/PluralKit.Bot/Commands/ImportExportCommands.cs index 826c8183..69b31d6d 100644 --- a/PluralKit.Bot/Commands/ImportExportCommands.cs +++ b/PluralKit.Bot/Commands/ImportExportCommands.cs @@ -17,7 +17,7 @@ namespace PluralKit.Bot.Commands [Remarks("import [fileurl]")] public async Task Import([Remainder] string url = null) { - if (url == null) url = Context.Message.Attachments.FirstOrDefault()?.Filename; + if (url == null) url = Context.Message.Attachments.FirstOrDefault()?.Url; if (url == null) throw Errors.NoImportFilePassed; await Context.BusyIndicator(async () => From 5f9d1cd16accce00c83a1df4a47d5ca179dd17cc Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 12:37:47 +0200 Subject: [PATCH 080/103] Add API container to Composefile --- docker-compose.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 263ea55d..3e95f486 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - "./pluralkit.conf:/app/pluralkit.conf:ro" links: - db + restart: always web: build: . entrypoint: ["dotnet", "run", "--project", "PluralKit.Web"] @@ -18,5 +19,17 @@ services: - db ports: - 2837:80 + restart: always + api: + build: . + entrypoint: ["dotnet", "run", "--project", "PluralKit.API"] + environment: + - "PluralKit:Database=Host=db;Username=postgres;Password=postgres;Database=postgres" + links: + - db + ports: + - 2838:80 + restart: always db: - image: postgres:alpine \ No newline at end of file + image: postgres:alpine + restart: always \ No newline at end of file From 5bdb229b3450b4b0613e13399207a22ee1454d49 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 12:49:09 +0200 Subject: [PATCH 081/103] Fix errant dollar sign in log embed --- PluralKit.Bot/Services/EmbedService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 86a30b2b..2278684e 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -46,7 +46,7 @@ namespace PluralKit.Bot { return new EmbedBuilder() .WithAuthor($"#{message.Channel.Name}: {member.Name}", member.AvatarUrl) .WithDescription(message.Content) - .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: ${sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: ${message.Id}") + .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: {sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: ${message.Id}") .WithTimestamp(message.Timestamp) .Build(); } From 740ccf6979712d926010b33323a2be1bc1eafa94 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 12:52:02 +0200 Subject: [PATCH 082/103] Fix sending message attachments --- PluralKit.Bot/Services/ProxyService.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index 065f1bcc..67178d59 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Data; using System.Linq; using System.Net; +using System.Net.Http; using System.Threading.Tasks; using Dapper; using Discord; @@ -100,7 +101,8 @@ namespace PluralKit.Bot ulong messageId; if (attachment != null) { - using (var stream = await WebRequest.CreateHttp(attachment.Url).GetRequestStreamAsync()) { + using (var http = new HttpClient()) + using (var stream = await http.GetStreamAsync(attachment.Url)) { messageId = await client.SendFileAsync(stream, filename: attachment.Filename, text: text, username: username, avatarUrl: avatarUrl); } } else { From 641532daec6fbf8d473f9771cb275b414d09bd1c Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 12:54:54 +0200 Subject: [PATCH 083/103] Fix API system fronter endpoint crashing on system with no switches --- PluralKit.API/Controllers/SystemController.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PluralKit.API/Controllers/SystemController.cs b/PluralKit.API/Controllers/SystemController.cs index 65cb575b..9024509c 100644 --- a/PluralKit.API/Controllers/SystemController.cs +++ b/PluralKit.API/Controllers/SystemController.cs @@ -92,6 +92,8 @@ namespace PluralKit.API.Controllers if (system == null) return NotFound("System not found."); var sw = await _switches.GetLatestSwitch(system); + if (sw == null) return NotFound("System has no registered switches."); + var members = await _switches.GetSwitchMembers(sw); return Ok(new FrontersReturn { From 2b508f80e95f6d082fd18c3d3defcf1c5053f4d2 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 13:20:57 +0200 Subject: [PATCH 084/103] Add API documentation to docs --- docs/3-api-documentation.md | 255 +++++++++++++++++++++++++++++++++++- 1 file changed, 254 insertions(+), 1 deletion(-) diff --git a/docs/3-api-documentation.md b/docs/3-api-documentation.md index fcee93bc..350ee408 100644 --- a/docs/3-api-documentation.md +++ b/docs/3-api-documentation.md @@ -4,4 +4,257 @@ title: API documentation permalink: /api --- -# TODO \ No newline at end of file +# API documentation +PluralKit has a basic HTTP REST API for querying and modifying your system. +The root endpoint of the API is `https://api.pluralkit.me/v1/`. + +Endpoints will always return all fields, using `null` when a value is missing. On `PATCH` endpoints, you *must* include +all fields, too. Missing fields will be interpreted as `null`, and `null` fields will have their value removed. To +preserve a value, pass the existing value again. + +## Authentication +Authentication is done with a simple "system token". You can get your system token by running `pk;token` using the +Discord bot, either in a channel with the bot or in DMs. Then, pass this token in the `Authorization` HTTP header +on requests that require it. Failure to do so on endpoints that require authentication will return a `401 Unauthorized`. + +## Models +The following three models (usually represented in JSON format) represent the various objects in PluralKit's API. A `?` after the column type indicates an optional (nullable) parameter. +### System model +|Key|Type|Patchable?|Notes| +|---|---|---|---| +|id|string|No| +|name|string?|Yes|100-character limit. +|description|string?|Yes|1000-character limit. +|tag|string?|Yes| +|avatar_url|url?|Yes|Not validated server-side. +|tz|string?|Yes|Tzdb identifier. Patching with `null` will store `"UTC"`. +|created|datetime|No| + +### Member model +|Key|Type|Patchable?|Notes| +|---|---|---|---| +|id|string|No| +|name|string?|Yes|50-character limit. +|description|string?|Yes|1000-character limit. +|color|color?|Yes|6-char hex (eg. `ff7000`), sans `#`. +|avatar_url|url?|Yes|Not validated server-side. +|birthday|date?|Yes|ISO-8601 (`YYYY-MM-DD`) format, year of `0001` means hidden year. +|prefix|string?|Yes|| +|suffix|string?|Yes|| +|created|datetime|No| +### Switch model +|Key|Type|Notes| +|---|---|---| +|timestamp|datetime| +|members|list of id/Member|Is sometimes in plain ID list form (eg. `GET /s/<id>/switches`), sometimes includes the full Member model (eg. `GET /s/<id>/fronters`). + +## Endpoints + +### `GET /s/<id>` +Queries a system by its 5-character ID, and returns information about it. If the system doesn't exist, returns `404 Not Found`. + +#### Example request + GET https://api.pluralkit.me/v1/s/abcde + +#### Example response +```json +{ + "id": "abcde", + "name": "My System", + "description": "This is my system description. Yay.", + "tag": "[MySys]", + "avatar_url": "https://path/to/avatar/image.png", + "tz": "Europe/Copenhagen", + "created": "2019-01-01T14:30:00.987654Z" +} +``` + +### `GET /s/<id>/members` +Queries a system's member list by its 5-character ID. If the system doesn't exist, returns `404 Not Found`. + +#### Example request + GET https://api.pluralkit.me/v1/s/abcde/members + +#### Example response +```json +[ + { + "id": "qwert", + "name": "Craig Johnson", + "color": "ff7000", + "avatar_url": "https://path/to/avatar/image.png", + "birthday": "1997-07-14", + "pronouns": "he/him or they/them", + "description": "I am Craig, example user extraordinaire.", + "prefix": "[", + "suffix": "]", + "created": "2019-01-01T15:00:00.654321Z" + } +] +``` + +### `GET /s/<id>/switches[?before=<timestamp>]` +Returns a system's switch history in newest-first chronological order, with a maximum of 100 switches. If the system doesn't exist, returns `404 Not Found`. +Optionally takes a `?before=` query parameter with an ISO-8601-formatted timestamp, and will only return switches +that happen before that timestamp. + +#### Example request + GET https://api.pluralkit.me/v1/s/abcde/switches?before=2019-03-01T14:00:00Z + +#### Example response +```json +[ + { + "timestamp": "2019-02-23T14:20:59.123456Z", + "members": ["qwert", "yuiop"] + }, + { + "timestamp": "2019-02-22T12:00:00Z", + "members": ["yuiop"] + }, + { + "timestamp": "2019-02-20T09:30:00Z", + "members": [] + } +] +``` + +### `GET /s/<id>/fronters` +Returns a system's current fronter(s), with fully hydrated member objects. If the system doesn't exist, *or* the system has no registered switches, returns `404 Not Found`. + +#### Example request + GET https://api.pluralkit.me/v1/s/abcde/fronters + +#### Example response +```json +{ + "timestamp": "2019-07-09T17:22:46.47441Z", + "members": [ + { + "id": "qwert", + "name": "Craig Johnson", + "color": "ff7000", + "avatar_url": "https://path/to/avatar/image.png", + "birthday": "1997-07-14", + "pronouns": "he/him or they/them", + "description": "I am Craig, example user extraordinaire.", + "prefix": "[", + "suffix": "]", + "created": "2019-01-01T15:00:00.654321Z" + } + ] +} +``` + +### `PATCH /s` +**Requires authentication.** + +Edits your own system's information. Missing fields will be set to `null`. Will return the new system object. + +#### Example request + PATCH https://api.pluralkit.me/v1/s + +```json +{ + "name": "New System Name", + "tag": "{Sys}", + "avatar_url": "https://path/to/new/avatar.png" + "tz": "America/New_York" +} +``` +(note the absence of a `description` field, which is set to null in the response) + +#### Example response +```json +{ + "id": "abcde", + "name": "New System Name", + "description": null, + "tag": "{Sys}", + "avatar_url": "https://path/to/new/avatar.png", + "tz": "America/New_York", + "created": "2019-01-01T14:30:00.987654Z" +} +``` + +### `POST /s/switches` +**Requires authentication.** + +Registers a new switch to your own system given a list of member IDs. + +#### Example request + POST https://api.pluralkit.me/v1/s/switches + +```json +{ + "members": ["qwert", "yuiop"] +} +``` + +#### Example response +(`204 No Content`) + +### `GET /m/<id>` +Queries a member's information by its 5-character member ID. If the member does not exist, will return `404 Not Found`. + +#### Example request + GET https://api.pluralkit.me/v1/m/qwert + +#### Example response +```json +{ + "id": "qwert", + "name": "Craig Johnson", + "color": "ff7000", + "avatar_url": "https://path/to/avatar/image.png", + "birthday": "1997-07-14", + "pronouns": "he/him or they/them", + "description": "I am Craig, example user extraordinaire.", + "prefix": "[", + "suffix": "]", + "created": "2019-01-01T15:00:00.654321Z" +} +``` + +### `PATCH /m/<id>` +**Requires authentication.** + +Edits a member's information. Missing fields will be set to `null`. Will return the new member object. Member must (obviously) belong to your own system. + +#### Example request + PATCH https://api.pluralkit.me/v1/m/qwert + +```json +{ + "name": "Craig Peterson", + "color": null, + "avatar_url": "https://path/to/new/image.png", + "birthday": "1997-07-14", + "pronouns": "they/them", + "description": "I am Craig, cooler example user extraordinaire.", + "prefix": "[" +} +``` +(note the absence of a `suffix` field, which is set to null in the response) + +#### Example response +```json +{ + "id": "qwert", + "name": "Craig Peterson", + "color": null, + "avatar_url": "https://path/to/new/image.png", + "birthday": "1997-07-14", + "pronouns": "they/them", + "description": "I am Craig, cooler example user extraordinaire.", + "prefix": "[", + "suffix": null, + "created": "2019-01-01T15:00:00.654321Z" +} +``` + +## Version history +* 2019-07-10 **(v1)** + * First specified version +* (prehistory) + * Initial release \ No newline at end of file From 352940abbd9b0257c614bd9c23a3aa61eb4f5b61 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 13:44:03 +0200 Subject: [PATCH 085/103] Sanitize user input in response messages --- PluralKit.Bot/Commands/MemberCommands.cs | 10 +++++----- PluralKit.Bot/Commands/MiscCommands.cs | 1 - PluralKit.Bot/Commands/ModCommands.cs | 2 +- PluralKit.Bot/Commands/SwitchCommands.cs | 8 ++++---- PluralKit.Bot/Commands/SystemCommands.cs | 18 ++++++++++++------ PluralKit.Bot/Errors.cs | 20 ++++++++++---------- PluralKit.Bot/Utils.cs | 4 ++++ 7 files changed, 36 insertions(+), 27 deletions(-) diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index ba9568e6..4ade46c4 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -38,7 +38,7 @@ namespace PluralKit.Bot.Commands // Warn if there's already a member by this name var existingMember = await Members.GetByName(Context.SenderSystem, memberName); if (existingMember != null) { - var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.Name}\" (with ID `{existingMember.Hid}`). Do you want to create another member with the same name?"); + var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.Name.Sanitize()}\" (with ID `{existingMember.Hid}`). Do you want to create another member with the same name?"); if (!await Context.PromptYesNo(msg)) throw new PKError("Member creation cancelled."); } @@ -46,7 +46,7 @@ namespace PluralKit.Bot.Commands var member = await Members.Create(Context.SenderSystem, memberName); // Send confirmation and space hint - await Context.Channel.SendMessageAsync($"{Emojis.Success} Member \"{memberName}\" (`{member.Hid}`) registered! Type `pk;help member` for a list of commands to edit this member."); + await Context.Channel.SendMessageAsync($"{Emojis.Success} Member \"{memberName.Sanitize()}\" (`{member.Hid}`) registered! Type `pk;help member` for a list of commands to edit this member."); if (memberName.Contains(" ")) await Context.Channel.SendMessageAsync($"{Emojis.Note} Note that this member's name contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it."); } @@ -69,7 +69,7 @@ namespace PluralKit.Bot.Commands // Warn if there's already a member by this name var existingMember = await Members.GetByName(Context.SenderSystem, newName); if (existingMember != null) { - var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.Name}\" (`{existingMember.Hid}`). Do you want to rename this member to that name too?"); + var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.Name.Sanitize()}\" (`{existingMember.Hid}`). Do you want to rename this member to that name too?"); if (!await Context.PromptYesNo(msg)) throw new PKError("Member renaming cancelled."); } @@ -170,7 +170,7 @@ namespace PluralKit.Bot.Commands ContextEntity.Prefix = prefixAndSuffix[0].Length > 0 ? prefixAndSuffix[0] : null; ContextEntity.Suffix = prefixAndSuffix[1].Length > 0 ? prefixAndSuffix[1] : null; await Members.Save(ContextEntity); - await Context.Channel.SendMessageAsync($"{Emojis.Success} Member proxy tags changed to `{ContextEntity.ProxyString}`. Try proxying now!"); + await Context.Channel.SendMessageAsync($"{Emojis.Success} Member proxy tags changed to `{ContextEntity.ProxyString.Sanitize()}`. Try proxying now!"); } [Command("delete")] @@ -179,7 +179,7 @@ namespace PluralKit.Bot.Commands [MustPassOwnMember] public async Task MemberDelete() { - await Context.Channel.SendMessageAsync($"{Emojis.Warn} Are you sure you want to delete \"{ContextEntity.Name}\"? If so, reply to this message with the member's ID (`{ContextEntity.Hid}`). __***This cannot be undone!***__"); + await Context.Channel.SendMessageAsync($"{Emojis.Warn} Are you sure you want to delete \"{ContextEntity.Name.Sanitize()}\"? If so, reply to this message with the member's ID (`{ContextEntity.Hid}`). __***This cannot be undone!***__"); if (!await Context.ConfirmWithReply(ContextEntity.Hid)) throw Errors.MemberDeleteCancelled; await Members.Delete(ContextEntity); await Context.Channel.SendMessageAsync($"{Emojis.Success} Member deleted."); diff --git a/PluralKit.Bot/Commands/MiscCommands.cs b/PluralKit.Bot/Commands/MiscCommands.cs index 13b1e29c..08890101 100644 --- a/PluralKit.Bot/Commands/MiscCommands.cs +++ b/PluralKit.Bot/Commands/MiscCommands.cs @@ -21,7 +21,6 @@ namespace PluralKit.Bot.Commands { sendMessages: true ); - // TODO: allow customization of invite ID var invite = $"https://discordapp.com/oauth2/authorize?client_id={clientId}&scope=bot&permissions={permissions.RawValue}"; await Context.Channel.SendMessageAsync($"{Emojis.Success} Use this link to add PluralKit to your server:\n<{invite}>"); } diff --git a/PluralKit.Bot/Commands/ModCommands.cs b/PluralKit.Bot/Commands/ModCommands.cs index 9d1071fa..08be09b9 100644 --- a/PluralKit.Bot/Commands/ModCommands.cs +++ b/PluralKit.Bot/Commands/ModCommands.cs @@ -20,7 +20,7 @@ namespace PluralKit.Bot.Commands await LogChannels.SetLogChannel(Context.Guild, channel); if (channel != null) - await Context.Channel.SendMessageAsync($"{Emojis.Success} Proxy logging channel set to #{channel.Name}."); + await Context.Channel.SendMessageAsync($"{Emojis.Success} Proxy logging channel set to #{channel.Name.Sanitize()}."); else await Context.Channel.SendMessageAsync($"{Emojis.Success} Proxy logging channel cleared."); } diff --git a/PluralKit.Bot/Commands/SwitchCommands.cs b/PluralKit.Bot/Commands/SwitchCommands.cs index c2f34418..a118ea61 100644 --- a/PluralKit.Bot/Commands/SwitchCommands.cs +++ b/PluralKit.Bot/Commands/SwitchCommands.cs @@ -52,7 +52,7 @@ namespace PluralKit.Bot.Commands if (members.Count == 0) await Context.Channel.SendMessageAsync($"{Emojis.Success} Switch-out registered."); else - await Context.Channel.SendMessageAsync($"{Emojis.Success} Switch registered. Current fronter is now {string.Join(", ", members.Select(m => m.Name))}."); + await Context.Channel.SendMessageAsync($"{Emojis.Success} Switch registered. Current fronter is now {string.Join(", ", members.Select(m => m.Name)).Sanitize()}."); } [Command("move")] @@ -91,7 +91,7 @@ namespace PluralKit.Bot.Commands var newSwitchDeltaStr = Formats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - time.ToInstant()); // yeet - var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} This will move the latest switch ({lastSwitchMemberStr}) from {lastSwitchTimeStr} ({lastSwitchDeltaStr} ago) to {newSwitchTimeStr} ({newSwitchDeltaStr} ago). Is this OK?"); + var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} This will move the latest switch ({lastSwitchMemberStr.Sanitize()}) from {lastSwitchTimeStr} ({lastSwitchDeltaStr} ago) to {newSwitchTimeStr} ({newSwitchDeltaStr} ago). Is this OK?"); if (!await Context.PromptYesNo(msg)) throw Errors.SwitchMoveCancelled; // aaaand *now* we do the move @@ -116,7 +116,7 @@ namespace PluralKit.Bot.Commands if (lastTwoSwitches.Length == 1) { msg = await Context.Channel.SendMessageAsync( - $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). You have no other switches logged. Is this okay?"); + $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr.Sanitize()}, {lastSwitchDeltaStr} ago). You have no other switches logged. Is this okay?"); } else { @@ -124,7 +124,7 @@ namespace PluralKit.Bot.Commands var secondSwitchMemberStr = string.Join(", ", secondSwitchMembers.Select(m => m.Name)); var secondSwitchDeltaStr = Formats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[1].Timestamp); msg = await Context.Channel.SendMessageAsync( - $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). The next latest switch is {secondSwitchMemberStr} ({secondSwitchDeltaStr} ago). Is this okay?"); + $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr.Sanitize()}, {lastSwitchDeltaStr} ago). The next latest switch is {secondSwitchMemberStr.Sanitize()} ({secondSwitchDeltaStr} ago). Is this okay?"); } if (!await Context.PromptYesNo(msg)) throw Errors.SwitchDeleteCancelled; diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index b3326551..d2894def 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -74,15 +74,21 @@ namespace PluralKit.Bot.Commands [Remarks("system tag <tag>")] [MustHaveSystem] public async Task Tag([Remainder] string newTag = null) { - if (newTag.Length > Limits.MaxSystemTagLength) throw Errors.SystemNameTooLongError(newTag.Length); Context.SenderSystem.Tag = newTag; - // Check unproxyable messages *after* changing the tag (so it's seen in the method) but *before* we save to DB (so we can cancel) - var unproxyableMembers = await Members.GetUnproxyableMembers(Context.SenderSystem); - if (unproxyableMembers.Count > 0) { - var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} Changing your system tag to '{newTag}' will result in the following members being unproxyable, since the tag would bring their name over 32 characters:\n**{string.Join(", ", unproxyableMembers.Select((m) => m.Name))}**\nDo you want to continue anyway?"); - if (!await Context.PromptYesNo(msg)) throw new PKError("Tag change cancelled."); + if (newTag != null) + { + if (newTag.Length > Limits.MaxSystemTagLength) throw Errors.SystemNameTooLongError(newTag.Length); + + // Check unproxyable messages *after* changing the tag (so it's seen in the method) but *before* we save to DB (so we can cancel) + var unproxyableMembers = await Members.GetUnproxyableMembers(Context.SenderSystem); + if (unproxyableMembers.Count > 0) + { + var msg = await Context.Channel.SendMessageAsync( + $"{Emojis.Warn} Changing your system tag to '{newTag}' will result in the following members being unproxyable, since the tag would bring their name over 32 characters:\n**{string.Join(", ", unproxyableMembers.Select((m) => m.Name))}**\nDo you want to continue anyway?"); + if (!await Context.PromptYesNo(msg)) throw new PKError("Tag change cancelled."); + } } await Systems.Save(Context.SenderSystem); diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 5ab98ffc..f9004e63 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -22,15 +22,15 @@ namespace PluralKit.Bot { public static PKError MemberNameTooLongError(int length) => new PKError($"Member name too long ({length}/{Limits.MaxMemberNameLength} characters)."); public static PKError MemberPronounsTooLongError(int length) => new PKError($"Member pronouns too long ({length}/{Limits.MaxMemberNameLength} characters)."); - public static PKError InvalidColorError(string color) => new PKError($"\"{color}\" is not a valid color. Color must be in 6-digit RGB hex format (eg. #ff0000)."); - public static PKError BirthdayParseError(string birthday) => new PKError($"\"{birthday}\" could not be parsed as a valid date. Try a format like \"2016-12-24\" or \"May 3 1996\"."); + public static PKError InvalidColorError(string color) => new PKError($"\"{color.Sanitize()}\" is not a valid color. Color must be in 6-digit RGB hex format (eg. #ff0000)."); + public static PKError BirthdayParseError(string birthday) => new PKError($"\"{birthday.Sanitize()}\" could not be parsed as a valid date. Try a format like \"2016-12-24\" or \"May 3 1996\"."); public static PKError ProxyMustHaveText => new PKSyntaxError("Example proxy message must contain the string 'text'."); public static PKError ProxyMultipleText => new PKSyntaxError("Example proxy message must contain the string 'text' exactly once."); public static PKError MemberDeleteCancelled => new PKError($"Member deletion cancelled. Stay safe! {Emojis.ThumbsUp}"); public static PKError AvatarServerError(HttpStatusCode statusCode) => new PKError($"Server responded with status code {(int) statusCode}, are you sure your link is working?"); public static PKError AvatarFileSizeLimit(long size) => new PKError($"File size too large ({size.Bytes().ToString("#.#")} > {Limits.AvatarFileSizeLimit.Bytes().ToString("#.#")}), try shrinking or compressing the image."); - public static PKError AvatarNotAnImage(string mimeType) => new PKError($"The given link does not point to an image{(mimeType != null ? $" ({mimeType})" : "")}. Make sure you're using a direct link (ending in .jpg, .png, .gif)."); + public static PKError AvatarNotAnImage(string mimeType) => new PKError($"The given link does not point to an image{(mimeType != null ? $" ({mimeType.Sanitize()})" : "")}. Make sure you're using a direct link (ending in .jpg, .png, .gif)."); public static PKError AvatarDimensionsTooLarge(int width, int height) => new PKError($"Image too large ({width}x{height} > {Limits.AvatarDimensionLimit}x{Limits.AvatarDimensionLimit}), try resizing the image."); public static PKError InvalidUrl(string url) => new PKError($"The given URL is invalid."); @@ -44,30 +44,30 @@ namespace PluralKit.Bot { public static PKError SameSwitch(ICollection<PKMember> members) { if (members.Count == 0) return new PKError("There's already no one in front."); - if (members.Count == 1) return new PKError($"Member {members.First().Name} is already fronting."); - return new PKError($"Members {string.Join(", ", members.Select(m => m.Name))} are already fronting."); + if (members.Count == 1) return new PKError($"Member {members.First().Name.Sanitize()} is already fronting."); + return new PKError($"Members {string.Join(", ", members.Select(m => m.Name.Sanitize()))} are already fronting."); } public static PKError DuplicateSwitchMembers => new PKError("Duplicate members in member list."); public static PKError SwitchMemberNotInSystem => new PKError("One or more switch members aren't in your own system."); - public static PKError InvalidDateTime(string str) => new PKError($"Could not parse '{str}' as a valid date/time. Try using a syntax such as \"May 21, 12:30 PM\" or \"3d12h\" (ie. 3 days, 12 hours ago)."); + public static PKError InvalidDateTime(string str) => new PKError($"Could not parse '{str.Sanitize()}' as a valid date/time. Try using a syntax such as \"May 21, 12:30 PM\" or \"3d12h\" (ie. 3 days, 12 hours ago)."); public static PKError SwitchTimeInFuture => new PKError("Can't move switch to a time in the future."); public static PKError NoRegisteredSwitches => new PKError("There are no registered switches for this system."); public static PKError SwitchMoveBeforeSecondLast(ZonedDateTime time) => new PKError($"Can't move switch to before last switch time ({Formats.ZonedDateTimeFormat.Format(time)}), as it would cause conflicts."); public static PKError SwitchMoveCancelled => new PKError("Switch move cancelled."); public static PKError SwitchDeleteCancelled => new PKError("Switch deletion cancelled."); - public static PKError TimezoneParseError(string timezone) => new PKError($"Could not parse timezone offset {timezone}. Offset must be a value like 'UTC+5' or 'GMT-4:30'."); + public static PKError TimezoneParseError(string timezone) => new PKError($"Could not parse timezone offset {timezone.Sanitize()}. Offset must be a value like 'UTC+5' or 'GMT-4:30'."); - public static PKError InvalidTimeZone(string zoneStr) => new PKError($"Invalid time zone ID '{zoneStr}'. To find your time zone ID, use the following website: <https://xske.github.io/tz>"); + public static PKError InvalidTimeZone(string zoneStr) => new PKError($"Invalid time zone ID '{zoneStr.Sanitize()}'. To find your time zone ID, use the following website: <https://xske.github.io/tz>"); public static PKError TimezoneChangeCancelled => new PKError("Time zone change cancelled."); - public static PKError AmbiguousTimeZone(string zoneStr, int count) => new PKError($"The time zone query '{zoneStr}' resulted in **{count}** different time zone regions. Try being more specific - e.g. pass an exact time zone specifier from the following website: <https://xske.github.io/tz>"); + public static PKError AmbiguousTimeZone(string zoneStr, int count) => new PKError($"The time zone query '{zoneStr.Sanitize()}' resulted in **{count}** different time zone regions. Try being more specific - e.g. pass an exact time zone specifier from the following website: <https://xske.github.io/tz>"); public static PKError NoImportFilePassed => new PKError("You must either pass an URL to a file as a command parameter, or as an attachment to the message containing the command."); public static PKError InvalidImportFile => new PKError("Imported data file invalid. Make sure this is a .json file directly exported from PluralKit or Tupperbox."); public static PKError ImportCancelled => new PKError("Import cancelled."); public static PKError MessageNotFound(ulong id) => new PKError($"Message with ID '{id}' not found. Are you sure it's a message proxied by PluralKit?"); - public static PKError DurationParseError(string durationStr) => new PKError($"Could not parse '{durationStr}' as a valid duration. Try a format such as `30d`, `1d3h` or `20m30s`."); + public static PKError DurationParseError(string durationStr) => new PKError($"Could not parse '{durationStr.Sanitize()}' as a valid duration. Try a format such as `30d`, `1d3h` or `20m30s`."); } } \ No newline at end of file diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index f55458ed..341541b6 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -4,6 +4,7 @@ using System.Data; using System.Globalization; using System.Linq; using System.Net.Http; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Dapper; using Discord; @@ -82,6 +83,9 @@ namespace PluralKit.Bot argPos = num + 2; return true; } + + public static string Sanitize(this string input) => + Regex.Replace(Regex.Replace(input, "<@[!&]?(\\d{17,19})>", "<\\@$1>"), "@(everyone|here)", "@\u200B$1"); } class PKSystemTypeReader : TypeReader From 31173af87d2fcab7815da620c1c8a0773e351a19 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 13:55:48 +0200 Subject: [PATCH 086/103] Add more command aliases --- PluralKit.Bot/Commands/HelpCommands.cs | 43 +++++++++++++++--------- PluralKit.Bot/Commands/MemberCommands.cs | 5 +-- PluralKit.Bot/Commands/MiscCommands.cs | 1 + PluralKit.Bot/Commands/ModCommands.cs | 2 ++ PluralKit.Bot/Commands/SwitchCommands.cs | 3 ++ PluralKit.Bot/Commands/SystemCommands.cs | 9 +++++ 6 files changed, 46 insertions(+), 17 deletions(-) diff --git a/PluralKit.Bot/Commands/HelpCommands.cs b/PluralKit.Bot/Commands/HelpCommands.cs index 183d2e6f..ba705178 100644 --- a/PluralKit.Bot/Commands/HelpCommands.cs +++ b/PluralKit.Bot/Commands/HelpCommands.cs @@ -6,22 +6,35 @@ namespace PluralKit.Bot.Commands { public class HelpCommands: ModuleBase<PKCommandContext> { - [Command("help")] - [Remarks("help")] - public async Task HelpRoot([Remainder] string _ignored) + [Group("help")] + public class HelpGroup: ModuleBase<PKCommandContext> { - await Context.Channel.SendMessageAsync(embed: new EmbedBuilder() - .WithTitle("PluralKit") - .WithDescription("PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.") - .AddField("What is this for? What are systems?", "This bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks. This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account.") - .AddField("Why are people's names saying [BOT] next to them?", "These people are not actually bots, this is just a Discord limitation. See [the documentation](https://pluralkit.me/guide#proxying) for an in-depth explanation.") - .AddField("How do I get started?", "To get started using PluralKit, try running the following commands (of course replacing the relevant names with your own):\n**1**. `pk;system new` - Create a system (if you haven't already)\n**2**. `pk;member add John` - Add a new member to your system\n**3**. `pk;member John proxy [text]` - Set up [square brackets] as proxy tags\n**4**. You're done! You can now type [a message in brackets] and it'll be proxied appropriately.\n**5**. Optionally, you may set an avatar from the URL of an image with `pk;member John avatar [link to image]`, or from a file by typing `pk;member John avatar` and sending the message with an attached image.\n\nSee [the documentation](https://pluralkit.me/guide#member-management) for more information.") - .AddField("Useful tips", $"React with {Emojis.Error} on a proxied message to delete it (only if you sent it!)\nReact with {Emojis.RedQuestion} on a proxied message to look up information about it (like who sent it)\nType **`pk;invite`** to get a link to invite this bot to your own server!") - .AddField("More information", "For a full list of commands, see [the command list](https://pluralkit.me/commands).\nFor a more in-depth explanation of message proxying, see [the documentation](https://pluralkit.me/guide#proxying).\nIf you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there.") - .AddField("Support server", "We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78") - .WithFooter("By @Ske#6201 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/") - .WithColor(Color.Blue) - .Build()); + [Command("proxy")] + [Priority(1)] + [Remarks("help proxy")] + public async Task HelpProxy() + { + await Context.Channel.SendMessageAsync( + "The proxy help page has been moved! See the website: https://pluralkit.me/guide#proxying"); + } + + [Command] + [Remarks("help")] + public async Task HelpRoot([Remainder] string _ignored = null) + { + await Context.Channel.SendMessageAsync(embed: new EmbedBuilder() + .WithTitle("PluralKit") + .WithDescription("PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.") + .AddField("What is this for? What are systems?", "This bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks. This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account.") + .AddField("Why are people's names saying [BOT] next to them?", "These people are not actually bots, this is just a Discord limitation. See [the documentation](https://pluralkit.me/guide#proxying) for an in-depth explanation.") + .AddField("How do I get started?", "To get started using PluralKit, try running the following commands (of course replacing the relevant names with your own):\n**1**. `pk;system new` - Create a system (if you haven't already)\n**2**. `pk;member add John` - Add a new member to your system\n**3**. `pk;member John proxy [text]` - Set up [square brackets] as proxy tags\n**4**. You're done! You can now type [a message in brackets] and it'll be proxied appropriately.\n**5**. Optionally, you may set an avatar from the URL of an image with `pk;member John avatar [link to image]`, or from a file by typing `pk;member John avatar` and sending the message with an attached image.\n\nSee [the documentation](https://pluralkit.me/guide#member-management) for more information.") + .AddField("Useful tips", $"React with {Emojis.Error} on a proxied message to delete it (only if you sent it!)\nReact with {Emojis.RedQuestion} on a proxied message to look up information about it (like who sent it)\nType **`pk;invite`** to get a link to invite this bot to your own server!") + .AddField("More information", "For a full list of commands, see [the command list](https://pluralkit.me/commands).\nFor a more in-depth explanation of message proxying, see [the documentation](https://pluralkit.me/guide#proxying).\nIf you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there.") + .AddField("Support server", "We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78") + .WithFooter("By @Ske#6201 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/") + .WithColor(Color.Blue) + .Build()); + } } [Command("commands")] diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index 4ade46c4..b9db1d9c 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -23,6 +23,7 @@ namespace PluralKit.Bot.Commands public override string ContextNoun => "member"; [Command("new")] + [Alias("n", "add", "create", "register")] [Remarks("member new <name>")] [MustHaveSystem] public async Task NewMember([Remainder] string memberName) { @@ -82,7 +83,7 @@ namespace PluralKit.Bot.Commands } [Command("description")] - [Alias("info", "bio", "text")] + [Alias("info", "bio", "text", "desc")] [Remarks("member <member> description <description>")] [MustPassOwnMember] public async Task MemberDescription([Remainder] string description = null) { @@ -174,7 +175,7 @@ namespace PluralKit.Bot.Commands } [Command("delete")] - [Alias("remove", "erase", "yeet")] + [Alias("remove", "destroy", "erase", "yeet")] [Remarks("member <member> delete")] [MustPassOwnMember] public async Task MemberDelete() diff --git a/PluralKit.Bot/Commands/MiscCommands.cs b/PluralKit.Bot/Commands/MiscCommands.cs index 08890101..99ace985 100644 --- a/PluralKit.Bot/Commands/MiscCommands.cs +++ b/PluralKit.Bot/Commands/MiscCommands.cs @@ -7,6 +7,7 @@ namespace PluralKit.Bot.Commands { public BotConfig BotConfig { get; set; } [Command("invite")] + [Alias("inv")] [Remarks("invite")] public async Task Invite() { diff --git a/PluralKit.Bot/Commands/ModCommands.cs b/PluralKit.Bot/Commands/ModCommands.cs index 08be09b9..a61e0ed1 100644 --- a/PluralKit.Bot/Commands/ModCommands.cs +++ b/PluralKit.Bot/Commands/ModCommands.cs @@ -27,6 +27,7 @@ namespace PluralKit.Bot.Commands [Command("message")] [Remarks("message <messageid>")] + [Alias("msg")] public async Task GetMessage(ulong messageId) { var message = await Messages.Get(messageId); @@ -37,6 +38,7 @@ namespace PluralKit.Bot.Commands [Command("message")] [Remarks("message <messageid>")] + [Alias("msg")] public async Task GetMessage(IMessage msg) => await GetMessage(msg.Id); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/SwitchCommands.cs b/PluralKit.Bot/Commands/SwitchCommands.cs index a118ea61..8568e11f 100644 --- a/PluralKit.Bot/Commands/SwitchCommands.cs +++ b/PluralKit.Bot/Commands/SwitchCommands.cs @@ -22,6 +22,7 @@ namespace PluralKit.Bot.Commands public async Task Switch(params PKMember[] members) => await DoSwitchCommand(members); [Command("out")] + [Alias("none")] [Remarks("switch out")] [MustHaveSystem] public async Task SwitchOut() => await DoSwitchCommand(new PKMember[] { }); @@ -56,6 +57,7 @@ namespace PluralKit.Bot.Commands } [Command("move")] + [Alias("shift")] [Remarks("switch move <date/time>")] [MustHaveSystem] public async Task SwitchMove([Remainder] string str) @@ -101,6 +103,7 @@ namespace PluralKit.Bot.Commands [Command("delete")] [Remarks("switch delete")] + [Alias("remove", "erase", "cancel", "yeet")] [MustHaveSystem] public async Task SwitchDelete() { diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index d2894def..fad4cceb 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -37,6 +37,7 @@ namespace PluralKit.Bot.Commands } [Command("new")] + [Alias("register", "create", "init", "add", "make")] [Remarks("system new <name>")] public async Task New([Remainder] string systemName = null) { @@ -49,6 +50,7 @@ namespace PluralKit.Bot.Commands } [Command("name")] + [Alias("rename", "changename")] [Remarks("system name <name>")] [MustHaveSystem] public async Task Name([Remainder] string newSystemName = null) { @@ -60,6 +62,7 @@ namespace PluralKit.Bot.Commands } [Command("description")] + [Alias("desc")] [Remarks("system description <description>")] [MustHaveSystem] public async Task Description([Remainder] string newDescription = null) { @@ -96,6 +99,7 @@ namespace PluralKit.Bot.Commands } [Command("delete")] + [Alias("remove", "destroy", "erase", "yeet")] [Remarks("system delete")] [MustHaveSystem] public async Task Delete() { @@ -108,6 +112,7 @@ namespace PluralKit.Bot.Commands } [Group("list")] + [Alias("l", "members")] public class SystemListCommands: ModuleBase<PKCommandContext> { public MemberStore Members { get; set; } @@ -158,6 +163,7 @@ namespace PluralKit.Bot.Commands } [Command("fronter")] + [Alias("f", "front", "fronters")] [Remarks("system [system] fronter")] public async Task SystemFronter() { @@ -171,6 +177,7 @@ namespace PluralKit.Bot.Commands } [Command("fronthistory")] + [Alias("fh", "history", "switches")] [Remarks("system [system] fronthistory")] public async Task SystemFrontHistory() { @@ -184,6 +191,7 @@ namespace PluralKit.Bot.Commands } [Command("frontpercent")] + [Alias("frontbreakdown", "frontpercent", "front%", "fp")] [Remarks("system [system] frontpercent [duration]")] public async Task SystemFrontPercent(string durationStr = "30d") { @@ -201,6 +209,7 @@ namespace PluralKit.Bot.Commands } [Command("timezone")] + [Alias("tz")] [Remarks("system timezone [timezone]")] [MustHaveSystem] public async Task SystemTimezone([Remainder] string zoneStr = null) From 8e0e37ed54c2a5d2bb9bb88510d683d5cc3fec57 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 13:57:59 +0200 Subject: [PATCH 087/103] Yes. --- PluralKit.Bot/Commands/MiscCommands.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/PluralKit.Bot/Commands/MiscCommands.cs b/PluralKit.Bot/Commands/MiscCommands.cs index 99ace985..34afa2f8 100644 --- a/PluralKit.Bot/Commands/MiscCommands.cs +++ b/PluralKit.Bot/Commands/MiscCommands.cs @@ -25,5 +25,12 @@ namespace PluralKit.Bot.Commands { var invite = $"https://discordapp.com/oauth2/authorize?client_id={clientId}&scope=bot&permissions={permissions.RawValue}"; await Context.Channel.SendMessageAsync($"{Emojis.Success} Use this link to add PluralKit to your server:\n<{invite}>"); } + + [Command("mn")] public Task Mn() => Context.Channel.SendMessageAsync("Gotta catch 'em all!"); + [Command("fire")] public Task Fire() => Context.Channel.SendMessageAsync("*A giant lightning bolt promptly erupts into a pillar of fire as it hits your opponent.*"); + [Command("thunder")] public Task Thunder() => Context.Channel.SendMessageAsync("*A giant ball of lightning is conjured and fired directly at your opponent, vanquishing them.*"); + [Command("freeze")] public Task Freeze() => Context.Channel.SendMessageAsync("*A giant crystal ball of ice is charged and hurled toward your opponent, bursting open and freezing them solid on contact.*"); + [Command("starstorm")] public Task Starstorm() => Context.Channel.SendMessageAsync("*Vibrant colours burst forth from the sky as meteors rain down upon your opponent.*"); + } } \ No newline at end of file From d7ffa8830dc56e5c85dafcce27782fcc38d71d86 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 16:19:27 +0200 Subject: [PATCH 088/103] Update README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6566df43..efbeedb2 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ PluralKit is a Discord bot meant for plural communities. It has features like message proxying through webhooks, switch tracking, system and member profiles, and more. **Do you just want to add PluralKit to your server? If so, you don't need any of this. Use the bot's invite link: https://discordapp.com/oauth2/authorize?client_id=466378653216014359&scope=bot&permissions=536995904** + PluralKit has a Discord server for support, feedback, and discussion: https://discord.gg/PczBt78 # Requirements -Running the bot requires [.NET Core](https://dotnet.microsoft.com/download) (>=2.2) and a PostgreSQL database. +Running the bot requires [.NET Core](https://dotnet.microsoft.com/download) (v2.2) and a PostgreSQL database. # Configuration Configuring the bot is done through a JSON configuration file. An example of the configuration format can be seen in [`pluralkit.conf.example`](https://github.com/xSke/PluralKit/blob/master/pluralkit.conf.example). From 8afb2f892bac7ad597129025849d17fb2dbbcc93 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 16:19:38 +0200 Subject: [PATCH 089/103] Remove deprecated web interface --- web/.babelrc | 14 - web/.gitignore | 3 - web/app/API.js | 63 - web/app/App.vue | 84 - web/app/HomePage.vue | 9 - web/app/MemberCard.vue | 66 - web/app/MemberEditPage.vue | 93 - web/app/OAuthRedirectPage.vue | 20 - web/app/SystemEditPage.vue | 83 - web/app/SystemPage.vue | 113 -- web/app/index.js | 23 - web/index.html | 9 - web/package.json | 19 - web/yarn.lock | 3296 --------------------------------- 14 files changed, 3895 deletions(-) delete mode 100644 web/.babelrc delete mode 100644 web/.gitignore delete mode 100644 web/app/API.js delete mode 100644 web/app/App.vue delete mode 100644 web/app/HomePage.vue delete mode 100644 web/app/MemberCard.vue delete mode 100644 web/app/MemberEditPage.vue delete mode 100644 web/app/OAuthRedirectPage.vue delete mode 100644 web/app/SystemEditPage.vue delete mode 100644 web/app/SystemPage.vue delete mode 100644 web/app/index.js delete mode 100644 web/index.html delete mode 100644 web/package.json delete mode 100644 web/yarn.lock diff --git a/web/.babelrc b/web/.babelrc deleted file mode 100644 index bf180d09..00000000 --- a/web/.babelrc +++ /dev/null @@ -1,14 +0,0 @@ -{ - "presets": [ - [ - "env", - { - "targets": { - "browsers": [ - "last 2 Chrome versions" - ] - } - } - ] - ] -} \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore deleted file mode 100644 index 72f16bf3..00000000 --- a/web/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.cache/ -dist/ -node_modules/ \ No newline at end of file diff --git a/web/app/API.js b/web/app/API.js deleted file mode 100644 index 2ed195c2..00000000 --- a/web/app/API.js +++ /dev/null @@ -1,63 +0,0 @@ -import { EventEmitter } from "eventemitter3" - -const SITE_ROOT = process.env.NODE_ENV === "production" ? "https://pluralkit.me" : "http://localhost:1234"; -const API_ROOT = process.env.NODE_ENV === "production" ? "https://api.pluralkit.me" : "http://localhost:2939"; -const CLIENT_ID = process.env.NODE_ENV === "production" ? "466378653216014359" : "467772037541134367"; -export const AUTH_URI = `https://discordapp.com/api/oauth2/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(SITE_ROOT + "/auth/discord")}&response_type=code&scope=identify` - - -class API extends EventEmitter { - async init() { - this.token = localStorage.getItem("pk-token"); - if (this.token) { - this.me = await fetch(API_ROOT + "/s", {headers: {"X-Token": this.token}}).then(r => r.json()); - this.emit("update", this.me); - } - } - - async fetchSystem(id) { - return await fetch(API_ROOT + "/s/" + id).then(r => r.json()) || null; - } - - async fetchSystemMembers(id) { - return await fetch(API_ROOT + "/s/" + id + "/members").then(r => r.json()) || []; - } - - async fetchSystemSwitches(id) { - return await fetch(API_ROOT + "/s/" + id + "/switches").then(r => r.json()) || []; - } - - async fetchMember(id) { - return await fetch(API_ROOT + "/m/" + id).then(r => r.json()) || null; - } - - async saveSystem(system) { - return await fetch(API_ROOT + "/s", { - method: "PATCH", - headers: {"X-Token": this.token}, - body: JSON.stringify(system) - }); - } - - async login(code) { - this.token = await fetch(API_ROOT + "/discord_oauth", {method: "POST", body: code}).then(r => r.text()); - this.me = await fetch(API_ROOT + "/s", {headers: {"X-Token": this.token}}).then(r => r.json()); - - if (this.me) { - localStorage.setItem("pk-token", this.token); - this.emit("update", this.me); - } else { - this.logout(); - } - return this.me; - } - - logout() { - localStorage.removeItem("pk-token"); - this.emit("update", null); - this.token = null; - this.me = null; - } -} - -export default new API(); \ No newline at end of file diff --git a/web/app/App.vue b/web/app/App.vue deleted file mode 100644 index d99250ec..00000000 --- a/web/app/App.vue +++ /dev/null @@ -1,84 +0,0 @@ -<template> - <div class="app"> - <b-navbar> - <b-navbar-brand :to="{name: 'home'}">PluralKit</b-navbar-brand> - <b-navbar-toggle target="nav-collapse"></b-navbar-toggle> - <b-collapse id="nav-collapse" is-nav> - <b-navbar-nav class="ml-auto"> - <b-nav-item v-if="me" :to="{name: 'system', params: {id: me.id}}">My system</b-nav-item> - <b-nav-item variant="primary" :href="authUri" v-if="!me">Log in</b-nav-item> - <b-nav-item v-on:click="logout" v-if="me">Log out</b-nav-item> - </b-navbar-nav> - </b-collapse> - </b-navbar> - - <router-view :me="me"></router-view> - </div> -</template> - -<script> -import BCollapse from 'bootstrap-vue/es/components/collapse/collapse'; -import BNav from 'bootstrap-vue/es/components/nav/nav'; -import BNavItem from 'bootstrap-vue/es/components/nav/nav-item'; -import BNavbar from 'bootstrap-vue/es/components/navbar/navbar'; -import BNavbarBrand from 'bootstrap-vue/es/components/navbar/navbar-brand'; -import BNavbarNav from 'bootstrap-vue/es/components/navbar/navbar-nav'; -import BNavbarToggle from 'bootstrap-vue/es/components/navbar/navbar-toggle'; - -import API from "./API"; -import { AUTH_URI } from "./API"; - -export default { - data() { - return { - me: null - } - }, - created() { - API.on("update", this.apply); - API.init(); - }, - methods: { - apply(system) { - this.me = system; - }, - logout() { - API.logout(); - } - }, - computed: { - authUri() { - return AUTH_URI; - } - }, - components: {BCollapse, BNav, BNavItem, BNavbar, BNavbarBrand, BNavbarNav, BNavbarToggle} -}; -</script> - -<style lang="scss"> -$font-family-sans-serif: "PT Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default; -$container-max-widths: ( - sm: 540px, - md: 720px, - lg: 959px, - xl: 960px, -) !default; - - -@import '~bootstrap/scss/_functions'; -@import '~bootstrap/scss/_variables'; -@import '~bootstrap/scss/_mixins'; - -@import '~bootstrap/scss/_buttons'; -@import '~bootstrap/scss/_code'; -@import '~bootstrap/scss/_forms'; -@import '~bootstrap/scss/_grid'; -@import '~bootstrap/scss/_nav'; -@import '~bootstrap/scss/_navbar'; -@import '~bootstrap/scss/_reboot'; -@import '~bootstrap/scss/_type'; -@import '~bootstrap/scss/_utilities'; - -@import '~bootstrap-vue/src/index.scss'; -</style> - diff --git a/web/app/HomePage.vue b/web/app/HomePage.vue deleted file mode 100644 index a5180fbf..00000000 --- a/web/app/HomePage.vue +++ /dev/null @@ -1,9 +0,0 @@ -<template> - <h1>Hello</h1> -</template> - -<script> -export default { - -} -</script> diff --git a/web/app/MemberCard.vue b/web/app/MemberCard.vue deleted file mode 100644 index 2e6f7d5c..00000000 --- a/web/app/MemberCard.vue +++ /dev/null @@ -1,66 +0,0 @@ -<template> - <div class="member-card"> - <div - class="member-avatar" - :style="{backgroundImage: `url(${member.avatar_url})`, borderColor: member.color}" - ></div> - <div class="member-body"> - <span class="member-name">{{ member.name }}</span> - <div class="member-description">{{ member.description }}</div> - <ul class="taglist"> - <li> - <hash-icon></hash-icon> - {{ member.id }} - </li> - <li v-if="member.birthday"> - <calendar-icon></calendar-icon> - {{ member.birthday }} - </li> - <li v-if="member.pronouns"> - <message-circle-icon></message-circle-icon> - {{ member.pronouns }} - </li> - </ul> - </div> - </div> -</template> - -<script> -import CalendarIcon from "vue-feather-icons/icons/CalendarIcon"; -import HashIcon from "vue-feather-icons/icons/HashIcon"; -import MessageCircleIcon from "vue-feather-icons/icons/MessageCircleIcon"; - -export default { - props: ["member"], - components: { HashIcon, CalendarIcon, MessageCircleIcon } -}; -</script> - -<style lang="scss"> -.member-card { - display: flex; - flex-direction: row; - - .member-avatar { - margin: 1.5rem 1rem 0 0; - border-radius: 50%; - background-size: cover; - background-position: top center; - flex-basis: 4rem; - height: 4rem; - border: 4px solid white; - } - - .member-body { - flex: 1; - display: flex; - flex-direction: column; - padding: 1rem 1rem 1rem 0; - - .member-name { - font-size: 13pt; - font-weight: bold; - } - } -} -</style> diff --git a/web/app/MemberEditPage.vue b/web/app/MemberEditPage.vue deleted file mode 100644 index 6be51b9c..00000000 --- a/web/app/MemberEditPage.vue +++ /dev/null @@ -1,93 +0,0 @@ -<template> - <b-container v-if="loading" class="d-flex justify-content-center"> - <b-spinner class="m-5"></b-spinner> - </b-container> - <b-container v-else-if="error">Error</b-container> - <b-container v-else> - <h1>Editing "{{member.name}}"</h1> - - <b-form> - <b-form-group label="Name"> - <b-form-input v-model="member.name" required></b-form-input> - </b-form-group> - - <b-form-group label="Description"> - <b-form-textarea v-model="member.description" rows="3" max-rows="6"></b-form-textarea> - </b-form-group> - - <b-form-group label="Proxy tags"> - <b-row> - <b-col> - <b-input-group prepend="Prefix"> - <b-form-input class="text-right" v-model="member.prefix" placeholder="ex: ["></b-form-input> - </b-input-group> - </b-col> - <b-col> - <b-input-group append="Suffix"> - <b-form-input v-model="member.suffix" placeholder="ex: ]"></b-form-input> - </b-input-group> - </b-col> - <b-col></b-col> - </b-row> - <template - v-slot:description - v-if="member.prefix || member.suffix" - >Example proxy message: {{member.prefix}}text{{member.suffix}}</template> - <template v-slot:description v-else>(no prefix or suffix defined, proxying will be disabled)</template> - </b-form-group> - - <b-form-group label="Pronouns" description="Free text field - put anything you'd like :)"> - <b-form-input v-model="member.pronouns" placeholder="eg. he/him"></b-form-input> - </b-form-group> - - <b-row> - <b-col md> - <b-form-group label="Birthday"> - <b-input-group> - <b-input-group-prepend is-text> - <input type="checkbox" v-model="hideBirthday" label="uwu"> Hide year - </b-input-group-prepend> - <b-form-input v-model="member.birthday" type="date"></b-form-input> - </b-input-group> - </b-form-group> - </b-col> - <b-col md> - <b-form-group label="Color" description="Will be displayed on system profile cards."> - <b-form-input type="color" v-model="member.color"></b-form-input> - </b-form-group> - </b-col> - </b-row> - </b-form> - </b-container> -</template> - -<script> -import API from "./API"; - -export default { - props: ["id"], - data() { - return { - loading: false, - error: false, - hideBirthday: false, - member: null - }; - }, - created() { - this.fetch(); - }, - methods: { - async fetch() { - this.loading = true; - this.error = false; - this.member = await API.fetchMember(this.id); - if (!this.member) this.error = true; - this.loading = false; - } - } -}; -</script> - -<style> -</style> diff --git a/web/app/OAuthRedirectPage.vue b/web/app/OAuthRedirectPage.vue deleted file mode 100644 index 3bf32125..00000000 --- a/web/app/OAuthRedirectPage.vue +++ /dev/null @@ -1,20 +0,0 @@ -<template> - <b-container class="d-flex justify-content-center"><span class="sr-only">Loading...</span><b-spinner class="m-5"></b-spinner></b-container> -</template> - -<script> -import API from "./API"; - -export default { - async created() { - const code = this.$route.query.code; - if (!code) this.$router.push({ name: "home" }); - const me = await API.login(code); - if (me) this.$router.push({ name: "home" }); - } -} -</script> - -<style> - -</style> diff --git a/web/app/SystemEditPage.vue b/web/app/SystemEditPage.vue deleted file mode 100644 index 71bae2f1..00000000 --- a/web/app/SystemEditPage.vue +++ /dev/null @@ -1,83 +0,0 @@ -<template> - <b-container> - <b-container v-if="loading" class="d-flex justify-content-center"><b-spinner class="m-5"></b-spinner></b-container> - <b-form v-else> - <h1>Editing "{{ system.name || system.id }}"</h1> - <b-form-group label="System name"> - <b-form-input v-model="system.name" placeholder="Enter something..."></b-form-input> - </b-form-group> - - <b-form-group label="Description"> - <b-form-textarea v-model="system.description" placeholder="Enter something..." rows="3" max-rows="3" maxlength="1000"></b-form-textarea> - </b-form-group> - - <b-form-group label="System tag"> - <b-form-input maxlength="30" v-model="system.tag" placeholder="Enter something..."></b-form-input> - <template v-slot:description> - This is added to the names of proxied accounts. For example: <code>John {{ system.tag }}</code> - </template> - </b-form-group> - - <b-form-group class="d-flex justify-content-end"> - <b-button type="reset" variant="outline-secondary">Back</b-button> - <b-button v-if="!saving" type="submit" variant="primary" v-on:click="save">Save</b-button> - <b-button v-else variant="primary" disabled> - <b-spinner small></b-spinner> - <span class="sr-only">Saving...</span> - </b-button> - <b-form-group> - </b-form> - </b-container> -</template> - -<script> -import BButton from 'bootstrap-vue/es/components/button/button'; -import BContainer from 'bootstrap-vue/es/components/layout/container'; -import BLink from 'bootstrap-vue/es/components/link/link'; -import BSpinner from 'bootstrap-vue/es/components/spinner/spinner'; -import BForm from 'bootstrap-vue/es/components/form/form'; -import BFormInput from 'bootstrap-vue/es/components/form-input/form-input'; -import BFormGroup from 'bootstrap-vue/es/components/form-group/form-group'; -import BFormTextarea from 'bootstrap-vue/es/components/form-textarea/form-textarea'; - -import API from "./API"; - -export default { - data() { - return { - loading: false, - saving: false, - system: null - } - }, - props: ["me", "id"], - created() { - this.fetch() - }, - watch: { - "id": "fetch" - }, - methods: { - async fetch() { - this.loading = true; - this.system = await API.fetchSystem(this.id); - if (!this.me || !this.system || this.system.id != this.me.id) { - this.$router.push({name: "system", params: {id: this.id}}); - } - this.loading = false; - }, - async save() { - this.saving = true; - if (await API.saveSystem(this.system)) { - this.$router.push({ name: "system", params: {id: this.system.id} }); - } - this.saving = false; - } - }, - components: {BButton, BContainer, BLink, BSpinner, BForm, BFormGroup, BFormInput, BFormTextarea} -} -</script> - -<style> - -</style> diff --git a/web/app/SystemPage.vue b/web/app/SystemPage.vue deleted file mode 100644 index 7c2964db..00000000 --- a/web/app/SystemPage.vue +++ /dev/null @@ -1,113 +0,0 @@ -<template> - <b-container v-if="loading" class="d-flex justify-content-center"><b-spinner class="m-5"></b-spinner></b-container> - <b-container v-else-if="error">An error occurred.</b-container> - <b-container v-else> - <ul v-if="system" class="taglist"> - <li> - <hash-icon></hash-icon> - {{ system.id }} - </li> - <li v-if="system.tag"> - <tag-icon></tag-icon> - {{ system.tag }} - </li> - <li v-if="system.tz"> - <clock-icon></clock-icon> - {{ system.tz }} - </li> - <li v-if="isMine" class="ml-auto"> - <b-link :to="{name: 'edit-system', params: {id: system.id}}"> - <edit-2-icon></edit-2-icon> - Edit - </b-link> - </li> - </ul> - - <h1 v-if="system && system.name">{{ system.name }}</h1> - <div v-if="system && system.description">{{ system.description }}</div> - - <h2>Members</h2> - <div v-if="members"> - <MemberCard v-for="member in members" :member="member" :key="member.id"/> - </div> - </b-container> -</template> - -<script> -import API from "./API"; - -import BContainer from 'bootstrap-vue/es/components/layout/container'; -import BLink from 'bootstrap-vue/es/components/link/link'; -import BSpinner from 'bootstrap-vue/es/components/spinner/spinner'; - -import MemberCard from "./MemberCard.vue"; - -import Edit2Icon from "vue-feather-icons/icons/Edit2Icon"; -import ClockIcon from "vue-feather-icons/icons/ClockIcon"; -import HashIcon from "vue-feather-icons/icons/HashIcon"; -import TagIcon from "vue-feather-icons/icons/TagIcon"; - -export default { - data() { - return { - loading: false, - error: false, - system: null, - members: null - }; - }, - props: ["me", "id"], - created() { - this.fetch(); - }, - methods: { - async fetch() { - this.loading = true; - this.system = await API.fetchSystem(this.id); - if (!this.system) { - this.error = true; - this.loading = false; - return; - } - this.members = await API.fetchSystemMembers(this.id); - this.loading = false; - } - }, - watch: { - id: "fetch" - }, - computed: { - isMine() { - return this.system && this.me && this.me.id == this.system.id; - } - }, - components: { - Edit2Icon, - ClockIcon, - HashIcon, - TagIcon, - MemberCard, - BContainer, BLink, BSpinner - } -}; -</script> - -<style lang="scss"> -.taglist { - margin: 0; - padding: 0; - color: #aaa; - display: flex; - - li { - display: inline-block; - margin-right: 1rem; - list-style-type: none; - .feather { - display: inline-block; - margin-top: -2px; - width: 1em; - } - } -} -</style> diff --git a/web/app/index.js b/web/app/index.js deleted file mode 100644 index 4d20200f..00000000 --- a/web/app/index.js +++ /dev/null @@ -1,23 +0,0 @@ -import Vue from "vue"; - -import VueRouter from "vue-router"; -Vue.use(VueRouter); - -const App = () => import("./App.vue"); -const HomePage = () => import("./HomePage.vue"); -const SystemPage = () => import("./SystemPage.vue"); -const SystemEditPage = () => import("./SystemEditPage.vue"); -const MemberEditPage = () => import("./MemberEditPage.vue"); -const OAuthRedirectPage = () => import("./OAuthRedirectPage.vue"); - -const router = new VueRouter({ - mode: "history", - routes: [ - { name: "home", path: "/", component: HomePage }, - { name: "system", path: "/s/:id", component: SystemPage, props: true }, - { name: "edit-system", path: "/s/:id/edit", component: SystemEditPage, props: true }, - { name: "edit-member", path: "/m/:id/edit", component: MemberEditPage, props: true }, - { name: "auth-discord", path: "/auth/discord", component: OAuthRedirectPage } - ] -}) -new Vue({ el: "#app", render: r => r(App), router }); \ No newline at end of file diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 82800e8f..00000000 --- a/web/index.html +++ /dev/null @@ -1,9 +0,0 @@ -<html> - <head> - <link href="https://fonts.googleapis.com/css?family=PT+Sans:400,700" rel="stylesheet"> - </head> - <body> - <div id="app"></div> - <script src="app/index.js"></script> - </body> -</html> \ No newline at end of file diff --git a/web/package.json b/web/package.json deleted file mode 100644 index 553896cd..00000000 --- a/web/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "dependencies": { - "bootstrap": "^4.3.1", - "bootstrap-vue": "^2.0.0-rc.16", - "eventemitter3": "^3.1.0", - "vue": "^2.6.10", - "vue-feather-icons": "^4.10.0", - "vue-router": "^3.0.2" - }, - "devDependencies": { - "@vue/component-compiler-utils": "^2.6.0", - "babel-core": "^6.26.3", - "babel-preset-env": "^1.7.0", - "cssnano": "^4.1.10", - "parcel-plugin-bundle-visualiser": "^1.2.0", - "sass": "^1.17.3", - "vue-template-compiler": "^2.6.10" - } -} diff --git a/web/yarn.lock b/web/yarn.lock deleted file mode 100644 index f64c61bd..00000000 --- a/web/yarn.lock +++ /dev/null @@ -1,3296 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@nuxt/opencollective@^0.2.1": - version "0.2.1" - resolved "https://registry.yarnpkg.com/@nuxt/opencollective/-/opencollective-0.2.1.tgz#8290f1220072637e575c3935733719a78ad2d056" - integrity sha512-NP2VSUKRFGutbhWeKgIU0MnY4fmpH8UWxxwTJNPurCQ5BeWhOxp+Gp5ltO39P/Et/J2GYGb3+ALNqZJ+5cGBBw== - dependencies: - chalk "^2.4.1" - consola "^2.3.0" - node-fetch "^2.3.0" - -"@types/q@^1.5.1": - version "1.5.2" - resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" - integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== - -"@vue/component-compiler-utils@^2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@vue/component-compiler-utils/-/component-compiler-utils-2.6.0.tgz#aa46d2a6f7647440b0b8932434d22f12371e543b" - integrity sha512-IHjxt7LsOFYc0DkTncB7OXJL7UzwOLPPQCfEUNyxL2qt+tF12THV+EO33O1G2Uk4feMSWua3iD39Itszx0f0bw== - dependencies: - consolidate "^0.15.1" - hash-sum "^1.0.2" - lru-cache "^4.1.2" - merge-source-map "^1.1.0" - postcss "^7.0.14" - postcss-selector-parser "^5.0.0" - prettier "1.16.3" - source-map "~0.6.1" - vue-template-es2015-compiler "^1.9.0" - -abbrev@1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== - -alphanum-sort@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" - integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= - -ansi-regex@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" - integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= - -ansi-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" - integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= - -ansi-styles@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" - integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= - -ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -anymatch@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" - integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== - dependencies: - micromatch "^3.1.4" - normalize-path "^2.1.1" - -aproba@^1.0.3: - version "1.2.0" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" - integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== - -are-we-there-yet@~1.1.2: - version "1.1.5" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" - integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== - dependencies: - delegates "^1.0.0" - readable-stream "^2.0.6" - -argparse@^1.0.7: - version "1.0.10" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" - integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== - dependencies: - sprintf-js "~1.0.2" - -arr-diff@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" - integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= - -arr-flatten@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" - integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== - -arr-union@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" - integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= - -array-unique@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" - integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= - -assign-symbols@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" - integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= - -async-each@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.2.tgz#8b8a7ca2a658f927e9f307d6d1a42f4199f0f735" - integrity sha512-6xrbvN0MOBKSJDdonmSSz2OwFSgxRaVtBDes26mj9KIGtDo+g9xosFRSC+i1gQh2oAN/tQ62AI/pGZGQjVOiRg== - -atob@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" - integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== - -babel-code-frame@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" - integrity sha1-Y/1D99weO7fONZR9uP42mj9Yx0s= - dependencies: - chalk "^1.1.3" - esutils "^2.0.2" - js-tokens "^3.0.2" - -babel-core@^6.26.0, babel-core@^6.26.3: - version "6.26.3" - resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.3.tgz#b2e2f09e342d0f0c88e2f02e067794125e75c207" - integrity sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA== - dependencies: - babel-code-frame "^6.26.0" - babel-generator "^6.26.0" - babel-helpers "^6.24.1" - babel-messages "^6.23.0" - babel-register "^6.26.0" - babel-runtime "^6.26.0" - babel-template "^6.26.0" - babel-traverse "^6.26.0" - babel-types "^6.26.0" - babylon "^6.18.0" - convert-source-map "^1.5.1" - debug "^2.6.9" - json5 "^0.5.1" - lodash "^4.17.4" - minimatch "^3.0.4" - path-is-absolute "^1.0.1" - private "^0.1.8" - slash "^1.0.0" - source-map "^0.5.7" - -babel-generator@^6.26.0: - version "6.26.1" - resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.1.tgz#1844408d3b8f0d35a404ea7ac180f087a601bd90" - integrity sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA== - dependencies: - babel-messages "^6.23.0" - babel-runtime "^6.26.0" - babel-types "^6.26.0" - detect-indent "^4.0.0" - jsesc "^1.3.0" - lodash "^4.17.4" - source-map "^0.5.7" - trim-right "^1.0.1" - -babel-helper-builder-binary-assignment-operator-visitor@^6.24.1: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz#cce4517ada356f4220bcae8a02c2b346f9a56664" - integrity sha1-zORReto1b0IgvK6KAsKzRvmlZmQ= - dependencies: - babel-helper-explode-assignable-expression "^6.24.1" - babel-runtime "^6.22.0" - babel-types "^6.24.1" - -babel-helper-call-delegate@^6.24.1: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz#ece6aacddc76e41c3461f88bfc575bd0daa2df8d" - integrity sha1-7Oaqzdx25Bw0YfiL/Fdb0Nqi340= - dependencies: - babel-helper-hoist-variables "^6.24.1" - babel-runtime "^6.22.0" - babel-traverse "^6.24.1" - babel-types "^6.24.1" - -babel-helper-define-map@^6.24.1: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz#a5f56dab41a25f97ecb498c7ebaca9819f95be5f" - integrity sha1-pfVtq0GiX5fstJjH66ypgZ+Vvl8= - dependencies: - babel-helper-function-name "^6.24.1" - babel-runtime "^6.26.0" - babel-types "^6.26.0" - lodash "^4.17.4" - -babel-helper-explode-assignable-expression@^6.24.1: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz#f25b82cf7dc10433c55f70592d5746400ac22caa" - integrity sha1-8luCz33BBDPFX3BZLVdGQArCLKo= - dependencies: - babel-runtime "^6.22.0" - babel-traverse "^6.24.1" - babel-types "^6.24.1" - -babel-helper-function-name@^6.24.1: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz#d3475b8c03ed98242a25b48351ab18399d3580a9" - integrity sha1-00dbjAPtmCQqJbSDUasYOZ01gKk= - dependencies: - babel-helper-get-function-arity "^6.24.1" - babel-runtime "^6.22.0" - babel-template "^6.24.1" - babel-traverse "^6.24.1" - babel-types "^6.24.1" - -babel-helper-get-function-arity@^6.24.1: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz#8f7782aa93407c41d3aa50908f89b031b1b6853d" - integrity sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0= - dependencies: - babel-runtime "^6.22.0" - babel-types "^6.24.1" - -babel-helper-hoist-variables@^6.24.1: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz#1ecb27689c9d25513eadbc9914a73f5408be7a76" - integrity sha1-HssnaJydJVE+rbyZFKc/VAi+enY= - dependencies: - babel-runtime "^6.22.0" - babel-types "^6.24.1" - -babel-helper-optimise-call-expression@^6.24.1: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz#f7a13427ba9f73f8f4fa993c54a97882d1244257" - integrity sha1-96E0J7qfc/j0+pk8VKl4gtEkQlc= - dependencies: - babel-runtime "^6.22.0" - babel-types "^6.24.1" - -babel-helper-regex@^6.24.1: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz#325c59f902f82f24b74faceed0363954f6495e72" - integrity sha1-MlxZ+QL4LyS3T6zu0DY5VPZJXnI= - dependencies: - babel-runtime "^6.26.0" - babel-types "^6.26.0" - lodash "^4.17.4" - -babel-helper-remap-async-to-generator@^6.24.1: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz#5ec581827ad723fecdd381f1c928390676e4551b" - integrity sha1-XsWBgnrXI/7N04HxySg5BnbkVRs= - dependencies: - babel-helper-function-name "^6.24.1" - babel-runtime "^6.22.0" - babel-template "^6.24.1" - babel-traverse "^6.24.1" - babel-types "^6.24.1" - -babel-helper-replace-supers@^6.24.1: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz#bf6dbfe43938d17369a213ca8a8bf74b6a90ab1a" - integrity sha1-v22/5Dk40XNpohPKiov3S2qQqxo= - dependencies: - babel-helper-optimise-call-expression "^6.24.1" - babel-messages "^6.23.0" - babel-runtime "^6.22.0" - babel-template "^6.24.1" - babel-traverse "^6.24.1" - babel-types "^6.24.1" - -babel-helper-vue-jsx-merge-props@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-2.0.3.tgz#22aebd3b33902328e513293a8e4992b384f9f1b6" - integrity sha512-gsLiKK7Qrb7zYJNgiXKpXblxbV5ffSwR0f5whkPAaBAR4fhi6bwRZxX9wBlIc5M/v8CCkXUbXZL4N/nSE97cqg== - -babel-helpers@^6.24.1: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-helpers/-/babel-helpers-6.24.1.tgz#3471de9caec388e5c850e597e58a26ddf37602b2" - integrity sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI= - dependencies: - babel-runtime "^6.22.0" - babel-template "^6.24.1" - -babel-messages@^6.23.0: - version "6.23.0" - resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e" - integrity sha1-8830cDhYA1sqKVHG7F7fbGLyYw4= - dependencies: - babel-runtime "^6.22.0" - -babel-plugin-check-es2015-constants@^6.22.0: - version "6.22.0" - resolved "https://registry.yarnpkg.com/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz#35157b101426fd2ffd3da3f75c7d1e91835bbf8a" - integrity sha1-NRV7EBQm/S/9PaP3XH0ekYNbv4o= - dependencies: - babel-runtime "^6.22.0" - -babel-plugin-syntax-async-functions@^6.8.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95" - integrity sha1-ytnK0RkbWtY0vzCuCHI5HgZHvpU= - -babel-plugin-syntax-exponentiation-operator@^6.8.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz#9ee7e8337290da95288201a6a57f4170317830de" - integrity sha1-nufoM3KQ2pUoggGmpX9BcDF4MN4= - -babel-plugin-syntax-trailing-function-commas@^6.22.0: - version "6.22.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz#ba0360937f8d06e40180a43fe0d5616fff532cf3" - integrity sha1-ugNgk3+NBuQBgKQ/4NVhb/9TLPM= - -babel-plugin-transform-async-to-generator@^6.22.0: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz#6536e378aff6cb1d5517ac0e40eb3e9fc8d08761" - integrity sha1-ZTbjeK/2yx1VF6wOQOs+n8jQh2E= - dependencies: - babel-helper-remap-async-to-generator "^6.24.1" - babel-plugin-syntax-async-functions "^6.8.0" - babel-runtime "^6.22.0" - -babel-plugin-transform-es2015-arrow-functions@^6.22.0: - version "6.22.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz#452692cb711d5f79dc7f85e440ce41b9f244d221" - integrity sha1-RSaSy3EdX3ncf4XkQM5BufJE0iE= - dependencies: - babel-runtime "^6.22.0" - -babel-plugin-transform-es2015-block-scoped-functions@^6.22.0: - version "6.22.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz#bbc51b49f964d70cb8d8e0b94e820246ce3a6141" - integrity sha1-u8UbSflk1wy42OC5ToICRs46YUE= - dependencies: - babel-runtime "^6.22.0" - -babel-plugin-transform-es2015-block-scoping@^6.23.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz#d70f5299c1308d05c12f463813b0a09e73b1895f" - integrity sha1-1w9SmcEwjQXBL0Y4E7CgnnOxiV8= - dependencies: - babel-runtime "^6.26.0" - babel-template "^6.26.0" - babel-traverse "^6.26.0" - babel-types "^6.26.0" - lodash "^4.17.4" - -babel-plugin-transform-es2015-classes@^6.23.0: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz#5a4c58a50c9c9461e564b4b2a3bfabc97a2584db" - integrity sha1-WkxYpQyclGHlZLSyo7+ryXolhNs= - dependencies: - babel-helper-define-map "^6.24.1" - babel-helper-function-name "^6.24.1" - babel-helper-optimise-call-expression "^6.24.1" - babel-helper-replace-supers "^6.24.1" - babel-messages "^6.23.0" - babel-runtime "^6.22.0" - babel-template "^6.24.1" - babel-traverse "^6.24.1" - babel-types "^6.24.1" - -babel-plugin-transform-es2015-computed-properties@^6.22.0: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz#6fe2a8d16895d5634f4cd999b6d3480a308159b3" - integrity sha1-b+Ko0WiV1WNPTNmZttNICjCBWbM= - dependencies: - babel-runtime "^6.22.0" - babel-template "^6.24.1" - -babel-plugin-transform-es2015-destructuring@^6.23.0: - version "6.23.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz#997bb1f1ab967f682d2b0876fe358d60e765c56d" - integrity sha1-mXux8auWf2gtKwh2/jWNYOdlxW0= - dependencies: - babel-runtime "^6.22.0" - -babel-plugin-transform-es2015-duplicate-keys@^6.22.0: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz#73eb3d310ca969e3ef9ec91c53741a6f1576423e" - integrity sha1-c+s9MQypaePvnskcU3QabxV2Qj4= - dependencies: - babel-runtime "^6.22.0" - babel-types "^6.24.1" - -babel-plugin-transform-es2015-for-of@^6.23.0: - version "6.23.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz#f47c95b2b613df1d3ecc2fdb7573623c75248691" - integrity sha1-9HyVsrYT3x0+zC/bdXNiPHUkhpE= - dependencies: - babel-runtime "^6.22.0" - -babel-plugin-transform-es2015-function-name@^6.22.0: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz#834c89853bc36b1af0f3a4c5dbaa94fd8eacaa8b" - integrity sha1-g0yJhTvDaxrw86TF26qU/Y6sqos= - dependencies: - babel-helper-function-name "^6.24.1" - babel-runtime "^6.22.0" - babel-types "^6.24.1" - -babel-plugin-transform-es2015-literals@^6.22.0: - version "6.22.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz#4f54a02d6cd66cf915280019a31d31925377ca2e" - integrity sha1-T1SgLWzWbPkVKAAZox0xklN3yi4= - dependencies: - babel-runtime "^6.22.0" - -babel-plugin-transform-es2015-modules-amd@^6.22.0, babel-plugin-transform-es2015-modules-amd@^6.24.1: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz#3b3e54017239842d6d19c3011c4bd2f00a00d154" - integrity sha1-Oz5UAXI5hC1tGcMBHEvS8AoA0VQ= - dependencies: - babel-plugin-transform-es2015-modules-commonjs "^6.24.1" - babel-runtime "^6.22.0" - babel-template "^6.24.1" - -babel-plugin-transform-es2015-modules-commonjs@^6.23.0, babel-plugin-transform-es2015-modules-commonjs@^6.24.1: - version "6.26.2" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.2.tgz#58a793863a9e7ca870bdc5a881117ffac27db6f3" - integrity sha512-CV9ROOHEdrjcwhIaJNBGMBCodN+1cfkwtM1SbUHmvyy35KGT7fohbpOxkE2uLz1o6odKK2Ck/tz47z+VqQfi9Q== - dependencies: - babel-plugin-transform-strict-mode "^6.24.1" - babel-runtime "^6.26.0" - babel-template "^6.26.0" - babel-types "^6.26.0" - -babel-plugin-transform-es2015-modules-systemjs@^6.23.0: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz#ff89a142b9119a906195f5f106ecf305d9407d23" - integrity sha1-/4mhQrkRmpBhlfXxBuzzBdlAfSM= - dependencies: - babel-helper-hoist-variables "^6.24.1" - babel-runtime "^6.22.0" - babel-template "^6.24.1" - -babel-plugin-transform-es2015-modules-umd@^6.23.0: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz#ac997e6285cd18ed6176adb607d602344ad38468" - integrity sha1-rJl+YoXNGO1hdq22B9YCNErThGg= - dependencies: - babel-plugin-transform-es2015-modules-amd "^6.24.1" - babel-runtime "^6.22.0" - babel-template "^6.24.1" - -babel-plugin-transform-es2015-object-super@^6.22.0: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz#24cef69ae21cb83a7f8603dad021f572eb278f8d" - integrity sha1-JM72muIcuDp/hgPa0CH1cusnj40= - dependencies: - babel-helper-replace-supers "^6.24.1" - babel-runtime "^6.22.0" - -babel-plugin-transform-es2015-parameters@^6.23.0: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz#57ac351ab49caf14a97cd13b09f66fdf0a625f2b" - integrity sha1-V6w1GrScrxSpfNE7CfZv3wpiXys= - dependencies: - babel-helper-call-delegate "^6.24.1" - babel-helper-get-function-arity "^6.24.1" - babel-runtime "^6.22.0" - babel-template "^6.24.1" - babel-traverse "^6.24.1" - babel-types "^6.24.1" - -babel-plugin-transform-es2015-shorthand-properties@^6.22.0: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz#24f875d6721c87661bbd99a4622e51f14de38aa0" - integrity sha1-JPh11nIch2YbvZmkYi5R8U3jiqA= - dependencies: - babel-runtime "^6.22.0" - babel-types "^6.24.1" - -babel-plugin-transform-es2015-spread@^6.22.0: - version "6.22.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz#d6d68a99f89aedc4536c81a542e8dd9f1746f8d1" - integrity sha1-1taKmfia7cRTbIGlQujdnxdG+NE= - dependencies: - babel-runtime "^6.22.0" - -babel-plugin-transform-es2015-sticky-regex@^6.22.0: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz#00c1cdb1aca71112cdf0cf6126c2ed6b457ccdbc" - integrity sha1-AMHNsaynERLN8M9hJsLta0V8zbw= - dependencies: - babel-helper-regex "^6.24.1" - babel-runtime "^6.22.0" - babel-types "^6.24.1" - -babel-plugin-transform-es2015-template-literals@^6.22.0: - version "6.22.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz#a84b3450f7e9f8f1f6839d6d687da84bb1236d8d" - integrity sha1-qEs0UPfp+PH2g51taH2oS7EjbY0= - dependencies: - babel-runtime "^6.22.0" - -babel-plugin-transform-es2015-typeof-symbol@^6.23.0: - version "6.23.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz#dec09f1cddff94b52ac73d505c84df59dcceb372" - integrity sha1-3sCfHN3/lLUqxz1QXITfWdzOs3I= - dependencies: - babel-runtime "^6.22.0" - -babel-plugin-transform-es2015-unicode-regex@^6.22.0: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz#d38b12f42ea7323f729387f18a7c5ae1faeb35e9" - integrity sha1-04sS9C6nMj9yk4fxinxa4frrNek= - dependencies: - babel-helper-regex "^6.24.1" - babel-runtime "^6.22.0" - regexpu-core "^2.0.0" - -babel-plugin-transform-exponentiation-operator@^6.22.0: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz#2ab0c9c7f3098fa48907772bb813fe41e8de3a0e" - integrity sha1-KrDJx/MJj6SJB3cruBP+QejeOg4= - dependencies: - babel-helper-builder-binary-assignment-operator-visitor "^6.24.1" - babel-plugin-syntax-exponentiation-operator "^6.8.0" - babel-runtime "^6.22.0" - -babel-plugin-transform-regenerator@^6.22.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz#e0703696fbde27f0a3efcacf8b4dca2f7b3a8f2f" - integrity sha1-4HA2lvveJ/Cj78rPi03KL3s6jy8= - dependencies: - regenerator-transform "^0.10.0" - -babel-plugin-transform-strict-mode@^6.24.1: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz#d5faf7aa578a65bbe591cf5edae04a0c67020758" - integrity sha1-1fr3qleKZbvlkc9e2uBKDGcCB1g= - dependencies: - babel-runtime "^6.22.0" - babel-types "^6.24.1" - -babel-preset-env@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/babel-preset-env/-/babel-preset-env-1.7.0.tgz#dea79fa4ebeb883cd35dab07e260c1c9c04df77a" - integrity sha512-9OR2afuKDneX2/q2EurSftUYM0xGu4O2D9adAhVfADDhrYDaxXV0rBbevVYoY9n6nyX1PmQW/0jtpJvUNr9CHg== - dependencies: - babel-plugin-check-es2015-constants "^6.22.0" - babel-plugin-syntax-trailing-function-commas "^6.22.0" - babel-plugin-transform-async-to-generator "^6.22.0" - babel-plugin-transform-es2015-arrow-functions "^6.22.0" - babel-plugin-transform-es2015-block-scoped-functions "^6.22.0" - babel-plugin-transform-es2015-block-scoping "^6.23.0" - babel-plugin-transform-es2015-classes "^6.23.0" - babel-plugin-transform-es2015-computed-properties "^6.22.0" - babel-plugin-transform-es2015-destructuring "^6.23.0" - babel-plugin-transform-es2015-duplicate-keys "^6.22.0" - babel-plugin-transform-es2015-for-of "^6.23.0" - babel-plugin-transform-es2015-function-name "^6.22.0" - babel-plugin-transform-es2015-literals "^6.22.0" - babel-plugin-transform-es2015-modules-amd "^6.22.0" - babel-plugin-transform-es2015-modules-commonjs "^6.23.0" - babel-plugin-transform-es2015-modules-systemjs "^6.23.0" - babel-plugin-transform-es2015-modules-umd "^6.23.0" - babel-plugin-transform-es2015-object-super "^6.22.0" - babel-plugin-transform-es2015-parameters "^6.23.0" - babel-plugin-transform-es2015-shorthand-properties "^6.22.0" - babel-plugin-transform-es2015-spread "^6.22.0" - babel-plugin-transform-es2015-sticky-regex "^6.22.0" - babel-plugin-transform-es2015-template-literals "^6.22.0" - babel-plugin-transform-es2015-typeof-symbol "^6.23.0" - babel-plugin-transform-es2015-unicode-regex "^6.22.0" - babel-plugin-transform-exponentiation-operator "^6.22.0" - babel-plugin-transform-regenerator "^6.22.0" - browserslist "^3.2.6" - invariant "^2.2.2" - semver "^5.3.0" - -babel-register@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-register/-/babel-register-6.26.0.tgz#6ed021173e2fcb486d7acb45c6009a856f647071" - integrity sha1-btAhFz4vy0htestFxgCahW9kcHE= - dependencies: - babel-core "^6.26.0" - babel-runtime "^6.26.0" - core-js "^2.5.0" - home-or-tmp "^2.0.0" - lodash "^4.17.4" - mkdirp "^0.5.1" - source-map-support "^0.4.15" - -babel-runtime@^6.18.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" - integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= - dependencies: - core-js "^2.4.0" - regenerator-runtime "^0.11.0" - -babel-template@^6.24.1, babel-template@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.26.0.tgz#de03e2d16396b069f46dd9fff8521fb1a0e35e02" - integrity sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI= - dependencies: - babel-runtime "^6.26.0" - babel-traverse "^6.26.0" - babel-types "^6.26.0" - babylon "^6.18.0" - lodash "^4.17.4" - -babel-traverse@^6.24.1, babel-traverse@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee" - integrity sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4= - dependencies: - babel-code-frame "^6.26.0" - babel-messages "^6.23.0" - babel-runtime "^6.26.0" - babel-types "^6.26.0" - babylon "^6.18.0" - debug "^2.6.8" - globals "^9.18.0" - invariant "^2.2.2" - lodash "^4.17.4" - -babel-types@^6.19.0, babel-types@^6.24.1, babel-types@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497" - integrity sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc= - dependencies: - babel-runtime "^6.26.0" - esutils "^2.0.2" - lodash "^4.17.4" - to-fast-properties "^1.0.3" - -babylon@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3" - integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ== - -balanced-match@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" - integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= - -base@^0.11.1: - version "0.11.2" - resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" - integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== - dependencies: - cache-base "^1.0.1" - class-utils "^0.3.5" - component-emitter "^1.2.1" - define-property "^1.0.0" - isobject "^3.0.1" - mixin-deep "^1.2.0" - pascalcase "^0.1.1" - -binary-extensions@^1.0.0: - version "1.13.1" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" - integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== - -bluebird@^3.1.1: - version "3.5.3" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7" - integrity sha512-/qKPUQlaW1OyR51WeCPBvRnAlnZFUJkCSG5HzGnuIqhgyJtF+T94lFnn33eiazjRm2LAHVy2guNnaq48X9SJuw== - -boolbase@^1.0.0, boolbase@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" - integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= - -bootstrap-vue@^2.0.0-rc.16: - version "2.0.0-rc.16" - resolved "https://registry.yarnpkg.com/bootstrap-vue/-/bootstrap-vue-2.0.0-rc.16.tgz#7302313ad4c5e29e88b9009e26fb2c9b6d81a1fb" - integrity sha512-fhiyqG6i3ITF7fAzAjMexikGUgBZ/GTKQi0mCK48FacB5tiq2KUXE0Qilb/CW090PkqEw2W+7AP2/k5/dAa/MQ== - dependencies: - "@nuxt/opencollective" "^0.2.1" - bootstrap "^4.3.1" - popper.js "^1.14.7" - vue-functional-data-merge "^2.0.7" - -bootstrap@^4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.3.1.tgz#280ca8f610504d99d7b6b4bfc4b68cec601704ac" - integrity sha512-rXqOmH1VilAt2DyPzluTi2blhk17bO7ef+zLLPlWvG494pDxcM234pJ8wTc/6R40UWizAIIMgxjvxZg5kmsbag== - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -braces@^2.3.1, braces@^2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" - integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== - dependencies: - arr-flatten "^1.1.0" - array-unique "^0.3.2" - extend-shallow "^2.0.1" - fill-range "^4.0.0" - isobject "^3.0.1" - repeat-element "^1.1.2" - snapdragon "^0.8.1" - snapdragon-node "^2.0.1" - split-string "^3.0.2" - to-regex "^3.0.1" - -browserslist@^3.2.6: - version "3.2.8" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-3.2.8.tgz#b0005361d6471f0f5952797a76fc985f1f978fc6" - integrity sha512-WHVocJYavUwVgVViC0ORikPHQquXwVh939TaelZ4WDqpWgTX/FsGhl/+P4qBUAGcRvtOgDgC+xftNWWp2RUTAQ== - dependencies: - caniuse-lite "^1.0.30000844" - electron-to-chromium "^1.3.47" - -browserslist@^4.0.0: - version "4.5.3" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.5.3.tgz#969495c410314bc89f14e748505e58be968080f1" - integrity sha512-Tx/Jtrmh6vFg24AelzLwCaCq1IUJiMDM1x/LPzqbmbktF8Zo7F9ONUpOWsFK6TtdON95mSMaQUWqi0ilc8xM6g== - dependencies: - caniuse-lite "^1.0.30000955" - electron-to-chromium "^1.3.122" - node-releases "^1.1.12" - -cache-base@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" - integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== - dependencies: - collection-visit "^1.0.0" - component-emitter "^1.2.1" - get-value "^2.0.6" - has-value "^1.0.0" - isobject "^3.0.1" - set-value "^2.0.0" - to-object-path "^0.3.0" - union-value "^1.0.0" - unset-value "^1.0.0" - -caller-callsite@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" - integrity sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ= - dependencies: - callsites "^2.0.0" - -caller-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" - integrity sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ= - dependencies: - caller-callsite "^2.0.0" - -callsites@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" - integrity sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA= - -camelcase@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" - integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0= - -caniuse-api@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" - integrity sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw== - dependencies: - browserslist "^4.0.0" - caniuse-lite "^1.0.0" - lodash.memoize "^4.1.2" - lodash.uniq "^4.5.0" - -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000844, caniuse-lite@^1.0.30000955: - version "1.0.30000955" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000955.tgz#360fdb9a1e41d6dd996130411334e44a39e4446d" - integrity sha512-6AwmIKgqCYfDWWadRkAuZSHMQP4Mmy96xAXEdRBlN/luQhlRYOKgwOlZ9plpCOsVbBuqbTmGqDK3JUM/nlr8CA== - -chalk@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" - integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= - dependencies: - ansi-styles "^2.2.1" - escape-string-regexp "^1.0.2" - has-ansi "^2.0.0" - strip-ansi "^3.0.0" - supports-color "^2.0.0" - -chalk@^2.4.1, chalk@^2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chokidar@^2.0.0: - version "2.1.5" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.5.tgz#0ae8434d962281a5f56c72869e79cb6d9d86ad4d" - integrity sha512-i0TprVWp+Kj4WRPtInjexJ8Q+BqTE909VpH8xVhXrJkoc5QC8VO9TryGOqTr+2hljzc1sC62t22h5tZePodM/A== - dependencies: - anymatch "^2.0.0" - async-each "^1.0.1" - braces "^2.3.2" - glob-parent "^3.1.0" - inherits "^2.0.3" - is-binary-path "^1.0.0" - is-glob "^4.0.0" - normalize-path "^3.0.0" - path-is-absolute "^1.0.0" - readdirp "^2.2.1" - upath "^1.1.1" - optionalDependencies: - fsevents "^1.2.7" - -chownr@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494" - integrity sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g== - -class-utils@^0.3.5: - version "0.3.6" - resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" - integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== - dependencies: - arr-union "^3.1.0" - define-property "^0.2.5" - isobject "^3.0.0" - static-extend "^0.1.1" - -cliui@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-4.1.0.tgz#348422dbe82d800b3022eef4f6ac10bf2e4d1b49" - integrity sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ== - dependencies: - string-width "^2.1.1" - strip-ansi "^4.0.0" - wrap-ansi "^2.0.0" - -coa@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/coa/-/coa-2.0.2.tgz#43f6c21151b4ef2bf57187db0d73de229e3e7ec3" - integrity sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA== - dependencies: - "@types/q" "^1.5.1" - chalk "^2.4.1" - q "^1.1.2" - -code-point-at@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" - integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= - -collection-visit@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" - integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= - dependencies: - map-visit "^1.0.0" - object-visit "^1.0.0" - -color-convert@^1.9.0, color-convert@^1.9.1: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= - -color-name@^1.0.0: - version "1.1.4" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - -color-string@^1.5.2: - version "1.5.3" - resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc" - integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw== - dependencies: - color-name "^1.0.0" - simple-swizzle "^0.2.2" - -color@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/color/-/color-3.1.0.tgz#d8e9fb096732875774c84bf922815df0308d0ffc" - integrity sha512-CwyopLkuRYO5ei2EpzpIh6LqJMt6Mt+jZhO5VI5f/wJLZriXQE32/SSqzmrh+QB+AZT81Cj8yv+7zwToW8ahZg== - dependencies: - color-convert "^1.9.1" - color-string "^1.5.2" - -component-emitter@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" - integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= - -consola@^2.3.0: - version "2.5.8" - resolved "https://registry.yarnpkg.com/consola/-/consola-2.5.8.tgz#26afe2ab7f560d285a88578eaae9d9be18029ba9" - integrity sha512-fYv1M0rNJw4h0CZUx8PX02Px7xQhA+vNHpV8DBCGMoozp2Io/vrSXhhEothaRnSt7VMR0rj2pt9KKLXa5amrCw== - -console-control-strings@^1.0.0, console-control-strings@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= - -consolidate@^0.15.1: - version "0.15.1" - resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.15.1.tgz#21ab043235c71a07d45d9aad98593b0dba56bab7" - integrity sha512-DW46nrsMJgy9kqAbPt5rKaCr7uFtpo4mSUvLHIUbJEjm0vo+aY5QLwBUq3FK4tRnJr/X0Psc0C4jf/h+HtXSMw== - dependencies: - bluebird "^3.1.1" - -convert-source-map@^1.5.1: - version "1.6.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" - integrity sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A== - dependencies: - safe-buffer "~5.1.1" - -copy-descriptor@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" - integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= - -core-js@^2.4.0, core-js@^2.5.0: - version "2.6.5" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.5.tgz#44bc8d249e7fb2ff5d00e0341a7ffb94fbf67895" - integrity sha512-klh/kDpwX8hryYL14M9w/xei6vrv6sE8gTHDG7/T/+SEovB/G4ejwcfE/CBzO6Edsu+OETZMZ3wcX/EjUkrl5A== - -core-util-is@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= - -cosmiconfig@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.2.0.tgz#45038e4d28a7fe787203aede9c25bca4a08b12c8" - integrity sha512-nxt+Nfc3JAqf4WIWd0jXLjTJZmsPLrA9DDc4nRw2KFJQJK7DNooqSXrNI7tzLG50CF8axczly5UV929tBmh/7g== - dependencies: - import-fresh "^2.0.0" - is-directory "^0.3.1" - js-yaml "^3.13.0" - parse-json "^4.0.0" - -cross-spawn@^5.0.1: - version "5.1.0" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" - integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk= - dependencies: - lru-cache "^4.0.1" - shebang-command "^1.2.0" - which "^1.2.9" - -css-color-names@0.0.4, css-color-names@^0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" - integrity sha1-gIrcLnnPhHOAabZGyyDsJ762KeA= - -css-declaration-sorter@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-4.0.1.tgz#c198940f63a76d7e36c1e71018b001721054cb22" - integrity sha512-BcxQSKTSEEQUftYpBVnsH4SF05NTuBokb19/sBt6asXGKZ/6VP7PLG1CBCkFDYOnhXhPh0jMhO6xZ71oYHXHBA== - dependencies: - postcss "^7.0.1" - timsort "^0.3.0" - -css-select-base-adapter@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz#3b2ff4972cc362ab88561507a95408a1432135d7" - integrity sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w== - -css-select@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-2.0.2.tgz#ab4386cec9e1f668855564b17c3733b43b2a5ede" - integrity sha512-dSpYaDVoWaELjvZ3mS6IKZM/y2PMPa/XYoEfYNZePL4U/XgyxZNroHEHReDx/d+VgXh9VbCTtFqLkFbmeqeaRQ== - dependencies: - boolbase "^1.0.0" - css-what "^2.1.2" - domutils "^1.7.0" - nth-check "^1.0.2" - -css-tree@1.0.0-alpha.28: - version "1.0.0-alpha.28" - resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.28.tgz#8e8968190d886c9477bc8d61e96f61af3f7ffa7f" - integrity sha512-joNNW1gCp3qFFzj4St6zk+Wh/NBv0vM5YbEreZk0SD4S23S+1xBKb6cLDg2uj4P4k/GUMlIm6cKIDqIG+vdt0w== - dependencies: - mdn-data "~1.1.0" - source-map "^0.5.3" - -css-tree@1.0.0-alpha.29: - version "1.0.0-alpha.29" - resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.29.tgz#3fa9d4ef3142cbd1c301e7664c1f352bd82f5a39" - integrity sha512-sRNb1XydwkW9IOci6iB2xmy8IGCj6r/fr+JWitvJ2JxQRPzN3T4AGGVWCMlVmVwM1gtgALJRmGIlWv5ppnGGkg== - dependencies: - mdn-data "~1.1.0" - source-map "^0.5.3" - -css-unit-converter@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/css-unit-converter/-/css-unit-converter-1.1.1.tgz#d9b9281adcfd8ced935bdbaba83786897f64e996" - integrity sha1-2bkoGtz9jO2TW9urqDeGiX9k6ZY= - -css-url-regex@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/css-url-regex/-/css-url-regex-1.1.0.tgz#83834230cc9f74c457de59eebd1543feeb83b7ec" - integrity sha1-g4NCMMyfdMRX3lnuvRVD/uuDt+w= - -css-what@^2.1.2: - version "2.1.3" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" - integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== - -cssesc@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-2.0.0.tgz#3b13bd1bb1cb36e1bcb5a4dcd27f54c5dcb35703" - integrity sha512-MsCAG1z9lPdoO/IUMLSBWBSVxVtJ1395VGIQ+Fc2gNdkQ1hNDnQdw3YhA71WJCBW1vdwA0cAnk/DnW6bqoEUYg== - -cssnano-preset-default@^4.0.7: - version "4.0.7" - resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76" - integrity sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA== - dependencies: - css-declaration-sorter "^4.0.1" - cssnano-util-raw-cache "^4.0.1" - postcss "^7.0.0" - postcss-calc "^7.0.1" - postcss-colormin "^4.0.3" - postcss-convert-values "^4.0.1" - postcss-discard-comments "^4.0.2" - postcss-discard-duplicates "^4.0.2" - postcss-discard-empty "^4.0.1" - postcss-discard-overridden "^4.0.1" - postcss-merge-longhand "^4.0.11" - postcss-merge-rules "^4.0.3" - postcss-minify-font-values "^4.0.2" - postcss-minify-gradients "^4.0.2" - postcss-minify-params "^4.0.2" - postcss-minify-selectors "^4.0.2" - postcss-normalize-charset "^4.0.1" - postcss-normalize-display-values "^4.0.2" - postcss-normalize-positions "^4.0.2" - postcss-normalize-repeat-style "^4.0.2" - postcss-normalize-string "^4.0.2" - postcss-normalize-timing-functions "^4.0.2" - postcss-normalize-unicode "^4.0.1" - postcss-normalize-url "^4.0.1" - postcss-normalize-whitespace "^4.0.2" - postcss-ordered-values "^4.1.2" - postcss-reduce-initial "^4.0.3" - postcss-reduce-transforms "^4.0.2" - postcss-svgo "^4.0.2" - postcss-unique-selectors "^4.0.1" - -cssnano-util-get-arguments@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/cssnano-util-get-arguments/-/cssnano-util-get-arguments-4.0.0.tgz#ed3a08299f21d75741b20f3b81f194ed49cc150f" - integrity sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8= - -cssnano-util-get-match@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/cssnano-util-get-match/-/cssnano-util-get-match-4.0.0.tgz#c0e4ca07f5386bb17ec5e52250b4f5961365156d" - integrity sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0= - -cssnano-util-raw-cache@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/cssnano-util-raw-cache/-/cssnano-util-raw-cache-4.0.1.tgz#b26d5fd5f72a11dfe7a7846fb4c67260f96bf282" - integrity sha512-qLuYtWK2b2Dy55I8ZX3ky1Z16WYsx544Q0UWViebptpwn/xDBmog2TLg4f+DBMg1rJ6JDWtn96WHbOKDWt1WQA== - dependencies: - postcss "^7.0.0" - -cssnano-util-same-parent@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/cssnano-util-same-parent/-/cssnano-util-same-parent-4.0.1.tgz#574082fb2859d2db433855835d9a8456ea18bbf3" - integrity sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q== - -cssnano@^4.1.10: - version "4.1.10" - resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-4.1.10.tgz#0ac41f0b13d13d465487e111b778d42da631b8b2" - integrity sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ== - dependencies: - cosmiconfig "^5.0.0" - cssnano-preset-default "^4.0.7" - is-resolvable "^1.0.0" - postcss "^7.0.0" - -csso@^3.5.1: - version "3.5.1" - resolved "https://registry.yarnpkg.com/csso/-/csso-3.5.1.tgz#7b9eb8be61628973c1b261e169d2f024008e758b" - integrity sha512-vrqULLffYU1Q2tLdJvaCYbONStnfkfimRxXNaGjxMldI0C7JPBC4rB1RyjhfdZ4m1frm8pM9uRPKH3d2knZ8gg== - dependencies: - css-tree "1.0.0-alpha.29" - -de-indent@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" - integrity sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0= - -debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -decamelize@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= - -decode-uri-component@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" - integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= - -deep-extend@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" - integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== - -define-properties@^1.1.2, define-properties@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" - integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== - dependencies: - object-keys "^1.0.12" - -define-property@^0.2.5: - version "0.2.5" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" - integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= - dependencies: - is-descriptor "^0.1.0" - -define-property@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" - integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= - dependencies: - is-descriptor "^1.0.0" - -define-property@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" - integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== - dependencies: - is-descriptor "^1.0.2" - isobject "^3.0.1" - -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= - -detect-indent@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208" - integrity sha1-920GQ1LN9Docts5hnE7jqUdd4gg= - dependencies: - repeating "^2.0.0" - -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - -dom-serializer@0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0" - integrity sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA== - dependencies: - domelementtype "^1.3.0" - entities "^1.1.1" - -domelementtype@1, domelementtype@^1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" - integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== - -domutils@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" - integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== - dependencies: - dom-serializer "0" - domelementtype "1" - -dot-prop@^4.1.1: - version "4.2.0" - resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57" - integrity sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ== - dependencies: - is-obj "^1.0.0" - -duplexer@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" - integrity sha1-rOb/gIwc5mtX0ev5eXessCM0z8E= - -electron-to-chromium@^1.3.122, electron-to-chromium@^1.3.47: - version "1.3.122" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.122.tgz#b32a0805f48557bd3c3b8104eadc7fa511b14a9a" - integrity sha512-3RKoIyCN4DhP2dsmleuFvpJAIDOseWH88wFYBzb22CSwoFDSWRc4UAMfrtc9h8nBdJjTNIN3rogChgOy6eFInw== - -entities@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" - integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== - -error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== - dependencies: - is-arrayish "^0.2.1" - -es-abstract@^1.12.0, es-abstract@^1.5.1: - version "1.13.0" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9" - integrity sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg== - dependencies: - es-to-primitive "^1.2.0" - function-bind "^1.1.1" - has "^1.0.3" - is-callable "^1.1.4" - is-regex "^1.0.4" - object-keys "^1.0.12" - -es-to-primitive@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.0.tgz#edf72478033456e8dda8ef09e00ad9650707f377" - integrity sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg== - dependencies: - is-callable "^1.1.4" - is-date-object "^1.0.1" - is-symbol "^1.0.2" - -escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= - -esprima@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - -esutils@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" - integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs= - -eventemitter3@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163" - integrity sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA== - -execa@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" - integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c= - dependencies: - cross-spawn "^5.0.1" - get-stream "^3.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - -expand-brackets@^2.1.4: - version "2.1.4" - resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" - integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= - dependencies: - debug "^2.3.3" - define-property "^0.2.5" - extend-shallow "^2.0.1" - posix-character-classes "^0.1.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" - -extend-shallow@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" - integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= - dependencies: - is-extendable "^0.1.0" - -extend-shallow@^3.0.0, extend-shallow@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" - integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= - dependencies: - assign-symbols "^1.0.0" - is-extendable "^1.0.1" - -extglob@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" - integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== - dependencies: - array-unique "^0.3.2" - define-property "^1.0.0" - expand-brackets "^2.1.4" - extend-shallow "^2.0.1" - fragment-cache "^0.2.1" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" - -filesize@^3.6.0: - version "3.6.1" - resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317" - integrity sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg== - -fill-range@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" - integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= - dependencies: - extend-shallow "^2.0.1" - is-number "^3.0.0" - repeat-string "^1.6.1" - to-regex-range "^2.1.0" - -find-up@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" - integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= - dependencies: - locate-path "^2.0.0" - -for-in@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" - integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= - -fragment-cache@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" - integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= - dependencies: - map-cache "^0.2.2" - -fs-minipass@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d" - integrity sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ== - dependencies: - minipass "^2.2.1" - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= - -fsevents@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.7.tgz#4851b664a3783e52003b3c66eb0eee1074933aa4" - integrity sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw== - dependencies: - nan "^2.9.2" - node-pre-gyp "^0.10.0" - -function-bind@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" - integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== - -gauge@~2.7.3: - version "2.7.4" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" - integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= - dependencies: - aproba "^1.0.3" - console-control-strings "^1.0.0" - has-unicode "^2.0.0" - object-assign "^4.1.0" - signal-exit "^3.0.0" - string-width "^1.0.1" - strip-ansi "^3.0.1" - wide-align "^1.1.0" - -get-caller-file@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" - integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== - -get-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" - integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= - -get-value@^2.0.3, get-value@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" - integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= - -glob-parent@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" - integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4= - dependencies: - is-glob "^3.1.0" - path-dirname "^1.0.0" - -glob@^7.1.3: - version "7.1.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" - integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -globals@^9.18.0: - version "9.18.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a" - integrity sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ== - -graceful-fs@^4.1.11: - version "4.1.15" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" - integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA== - -gzip-size@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-4.1.0.tgz#8ae096257eabe7d69c45be2b67c448124ffb517c" - integrity sha1-iuCWJX6r59acRb4rZ8RIEk/7UXw= - dependencies: - duplexer "^0.1.1" - pify "^3.0.0" - -has-ansi@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" - integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= - dependencies: - ansi-regex "^2.0.0" - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= - -has-symbols@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44" - integrity sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q= - -has-unicode@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= - -has-value@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" - integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= - dependencies: - get-value "^2.0.3" - has-values "^0.1.4" - isobject "^2.0.0" - -has-value@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" - integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= - dependencies: - get-value "^2.0.6" - has-values "^1.0.0" - isobject "^3.0.0" - -has-values@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" - integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= - -has-values@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" - integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= - dependencies: - is-number "^3.0.0" - kind-of "^4.0.0" - -has@^1.0.0, has@^1.0.1, has@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" - -hash-sum@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/hash-sum/-/hash-sum-1.0.2.tgz#33b40777754c6432573c120cc3808bbd10d47f04" - integrity sha1-M7QHd3VMZDJXPBIMw4CLvRDUfwQ= - -he@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" - integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== - -hex-color-regex@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" - integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== - -home-or-tmp@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" - integrity sha1-42w/LSyufXRqhX440Y1fMqeILbg= - dependencies: - os-homedir "^1.0.0" - os-tmpdir "^1.0.1" - -hsl-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" - integrity sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4= - -hsla-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/hsla-regex/-/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38" - integrity sha1-wc56MWjIxmFAM6S194d/OyJfnDg= - -html-comment-regex@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7" - integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== - -iconv-lite@^0.4.4: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -ignore-walk@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" - integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ== - dependencies: - minimatch "^3.0.4" - -import-fresh@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" - integrity sha1-2BNVwVYS04bGH53dOSLUMEgipUY= - dependencies: - caller-path "^2.0.0" - resolve-from "^3.0.0" - -indexes-of@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" - integrity sha1-8w9xbI4r00bHtn0985FVZqfAVgc= - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@^2.0.3, inherits@~2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= - -ini@~1.3.0: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== - -invariant@^2.2.2: - version "2.2.4" - resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" - integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== - dependencies: - loose-envify "^1.0.0" - -invert-kv@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" - integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= - -is-absolute-url@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" - integrity sha1-UFMN+4T8yap9vnhS6Do3uTufKqY= - -is-accessor-descriptor@^0.1.6: - version "0.1.6" - resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" - integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= - dependencies: - kind-of "^3.0.2" - -is-accessor-descriptor@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" - integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== - dependencies: - kind-of "^6.0.0" - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= - -is-arrayish@^0.3.1: - version "0.3.2" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" - integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== - -is-binary-path@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" - integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg= - dependencies: - binary-extensions "^1.0.0" - -is-buffer@^1.1.5: - version "1.1.6" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" - integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== - -is-callable@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75" - integrity sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA== - -is-color-stop@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-color-stop/-/is-color-stop-1.1.0.tgz#cfff471aee4dd5c9e158598fbe12967b5cdad345" - integrity sha1-z/9HGu5N1cnhWFmPvhKWe1za00U= - dependencies: - css-color-names "^0.0.4" - hex-color-regex "^1.1.0" - hsl-regex "^1.0.0" - hsla-regex "^1.0.0" - rgb-regex "^1.0.1" - rgba-regex "^1.0.0" - -is-data-descriptor@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" - integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= - dependencies: - kind-of "^3.0.2" - -is-data-descriptor@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" - integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== - dependencies: - kind-of "^6.0.0" - -is-date-object@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16" - integrity sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY= - -is-descriptor@^0.1.0: - version "0.1.6" - resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" - integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== - dependencies: - is-accessor-descriptor "^0.1.6" - is-data-descriptor "^0.1.4" - kind-of "^5.0.0" - -is-descriptor@^1.0.0, is-descriptor@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" - integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== - dependencies: - is-accessor-descriptor "^1.0.0" - is-data-descriptor "^1.0.0" - kind-of "^6.0.2" - -is-directory@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" - integrity sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE= - -is-extendable@^0.1.0, is-extendable@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" - integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= - -is-extendable@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" - integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== - dependencies: - is-plain-object "^2.0.4" - -is-extglob@^2.1.0, is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= - -is-finite@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa" - integrity sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko= - dependencies: - number-is-nan "^1.0.0" - -is-fullwidth-code-point@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" - integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= - dependencies: - number-is-nan "^1.0.0" - -is-fullwidth-code-point@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" - integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= - -is-glob@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" - integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo= - dependencies: - is-extglob "^2.1.0" - -is-glob@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" - integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== - dependencies: - is-extglob "^2.1.1" - -is-number@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" - integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= - dependencies: - kind-of "^3.0.2" - -is-obj@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" - integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= - -is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" - integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== - dependencies: - isobject "^3.0.1" - -is-regex@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" - integrity sha1-VRdIm1RwkbCTDglWVM7SXul+lJE= - dependencies: - has "^1.0.1" - -is-resolvable@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" - integrity sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg== - -is-stream@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" - integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= - -is-svg@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-3.0.0.tgz#9321dbd29c212e5ca99c4fa9794c714bcafa2f75" - integrity sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ== - dependencies: - html-comment-regex "^1.1.0" - -is-symbol@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.2.tgz#a055f6ae57192caee329e7a860118b497a950f38" - integrity sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw== - dependencies: - has-symbols "^1.0.0" - -is-windows@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" - integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== - -isarray@1.0.0, isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= - -isobject@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" - integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= - dependencies: - isarray "1.0.0" - -isobject@^3.0.0, isobject@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" - integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= - -"js-tokens@^3.0.0 || ^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -js-tokens@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" - integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= - -js-yaml@^3.12.0, js-yaml@^3.13.0: - version "3.13.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.0.tgz#38ee7178ac0eea2c97ff6d96fff4b18c7d8cf98e" - integrity sha512-pZZoSxcCYco+DIKBTimr67J6Hy+EYGZDY/HCWC+iAEA9h1ByhMXAIVUXMcMFpOCxQ/xjXmPI2MkDL5HRm5eFrQ== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - -jsesc@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b" - integrity sha1-RsP+yMGJKxKwgz25vHYiF226s0s= - -jsesc@~0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" - integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= - -json-parse-better-errors@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" - integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== - -json5@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" - integrity sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE= - -kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: - version "3.2.2" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" - integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= - dependencies: - is-buffer "^1.1.5" - -kind-of@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" - integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= - dependencies: - is-buffer "^1.1.5" - -kind-of@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" - integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== - -kind-of@^6.0.0, kind-of@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" - integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA== - -lcid@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" - integrity sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU= - dependencies: - invert-kv "^1.0.0" - -locate-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" - integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= - dependencies: - p-locate "^2.0.0" - path-exists "^3.0.0" - -lodash.memoize@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" - integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= - -lodash.uniq@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" - integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= - -lodash@^4.17.4: - version "4.17.11" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" - integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== - -loose-envify@^1.0.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" - integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== - dependencies: - js-tokens "^3.0.0 || ^4.0.0" - -lru-cache@^4.0.1, lru-cache@^4.1.2: - version "4.1.5" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" - integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== - dependencies: - pseudomap "^1.0.2" - yallist "^2.1.2" - -map-cache@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" - integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= - -map-visit@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" - integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= - dependencies: - object-visit "^1.0.0" - -mdn-data@~1.1.0: - version "1.1.4" - resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-1.1.4.tgz#50b5d4ffc4575276573c4eedb8780812a8419f01" - integrity sha512-FSYbp3lyKjyj3E7fMl6rYvUdX0FBXaluGqlFoYESWQlyUTq8R+wp0rkFxoYFqZlHCvsUXGjyJmLQSnXToYhOSA== - -mem@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76" - integrity sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y= - dependencies: - mimic-fn "^1.0.0" - -merge-source-map@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/merge-source-map/-/merge-source-map-1.1.0.tgz#2fdde7e6020939f70906a68f2d7ae685e4c8c646" - integrity sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw== - dependencies: - source-map "^0.6.1" - -micromatch@^3.1.10, micromatch@^3.1.4: - version "3.1.10" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" - integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== - dependencies: - arr-diff "^4.0.0" - array-unique "^0.3.2" - braces "^2.3.1" - define-property "^2.0.2" - extend-shallow "^3.0.2" - extglob "^2.0.4" - fragment-cache "^0.2.1" - kind-of "^6.0.2" - nanomatch "^1.2.9" - object.pick "^1.3.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.2" - -mimic-fn@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" - integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== - -minimatch@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" - integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== - dependencies: - brace-expansion "^1.1.7" - -minimist@0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" - integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= - -minimist@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" - integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= - -minipass@^2.2.1, minipass@^2.3.4: - version "2.3.5" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848" - integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA== - dependencies: - safe-buffer "^5.1.2" - yallist "^3.0.0" - -minizlib@^1.1.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.2.1.tgz#dd27ea6136243c7c880684e8672bb3a45fd9b614" - integrity sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA== - dependencies: - minipass "^2.2.1" - -mixin-deep@^1.2.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" - integrity sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ== - dependencies: - for-in "^1.0.2" - is-extendable "^1.0.1" - -mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" - integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= - dependencies: - minimist "0.0.8" - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= - -nan@^2.9.2: - version "2.13.2" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.13.2.tgz#f51dc7ae66ba7d5d55e1e6d4d8092e802c9aefe7" - integrity sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw== - -nanomatch@^1.2.9: - version "1.2.13" - resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" - integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== - dependencies: - arr-diff "^4.0.0" - array-unique "^0.3.2" - define-property "^2.0.2" - extend-shallow "^3.0.2" - fragment-cache "^0.2.1" - is-windows "^1.0.2" - kind-of "^6.0.2" - object.pick "^1.3.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" - -needle@^2.2.1: - version "2.2.4" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.4.tgz#51931bff82533b1928b7d1d69e01f1b00ffd2a4e" - integrity sha512-HyoqEb4wr/rsoaIDfTH2aVL9nWtQqba2/HvMv+++m8u0dz808MaagKILxtfeSN7QU7nvbQ79zk3vYOJp9zsNEA== - dependencies: - debug "^2.1.2" - iconv-lite "^0.4.4" - sax "^1.2.4" - -node-fetch@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.3.0.tgz#1a1d940bbfb916a1d3e0219f037e89e71f8c5fa5" - integrity sha512-MOd8pV3fxENbryESLgVIeaGKrdl+uaYhCSSVkjeOb/31/njTpcis5aWfdqgNlHIrKOLRbMnfPINPOML2CIFeXA== - -node-pre-gyp@^0.10.0: - version "0.10.3" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" - integrity sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4" - -node-releases@^1.1.12: - version "1.1.12" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.12.tgz#1d6baf544316b5422fcd35efe18708370a4e7637" - integrity sha512-Y+AQ1xdjcgaEzpL65PBEF3fnl1FNKnDh9Zm+AUQLIlyyqtSc4u93jyMN4zrjMzdwKQ10RTr3tgY1x7qpsfF/xg== - dependencies: - semver "^5.3.0" - -nopt@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" - integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= - dependencies: - abbrev "1" - osenv "^0.1.4" - -normalize-path@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" - integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= - dependencies: - remove-trailing-separator "^1.0.1" - -normalize-path@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -normalize-url@^3.0.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" - integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== - -npm-bundled@^1.0.1: - version "1.0.6" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd" - integrity sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g== - -npm-packlist@^1.1.6: - version "1.4.1" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.1.tgz#19064cdf988da80ea3cee45533879d90192bbfbc" - integrity sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw== - dependencies: - ignore-walk "^3.0.1" - npm-bundled "^1.0.1" - -npm-run-path@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" - integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= - dependencies: - path-key "^2.0.0" - -npmlog@^4.0.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" - integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== - dependencies: - are-we-there-yet "~1.1.2" - console-control-strings "~1.1.0" - gauge "~2.7.3" - set-blocking "~2.0.0" - -nth-check@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" - integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg== - dependencies: - boolbase "~1.0.0" - -number-is-nan@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" - integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= - -object-assign@^4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= - -object-copy@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" - integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= - dependencies: - copy-descriptor "^0.1.0" - define-property "^0.2.5" - kind-of "^3.0.3" - -object-keys@^1.0.12: - version "1.1.0" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.0.tgz#11bd22348dd2e096a045ab06f6c85bcc340fa032" - integrity sha512-6OO5X1+2tYkNyNEx6TsCxEqFfRWaqx6EtMiSbGrw8Ob8v9Ne+Hl8rBAgLBZn5wjEz3s/s6U1WXFUFOcxxAwUpg== - -object-visit@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" - integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= - dependencies: - isobject "^3.0.0" - -object.getownpropertydescriptors@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz#8758c846f5b407adab0f236e0986f14b051caa16" - integrity sha1-h1jIRvW0B62rDyNuCYbxSwUcqhY= - dependencies: - define-properties "^1.1.2" - es-abstract "^1.5.1" - -object.pick@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" - integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= - dependencies: - isobject "^3.0.1" - -object.values@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.0.tgz#bf6810ef5da3e5325790eaaa2be213ea84624da9" - integrity sha512-8mf0nKLAoFX6VlNVdhGj31SVYpaNFtUnuoOXWyFEstsWRgU837AK+JYM0iAxwkSzGRbwn8cbFmgbyxj1j4VbXg== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.12.0" - function-bind "^1.1.1" - has "^1.0.3" - -once@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= - dependencies: - wrappy "1" - -os-homedir@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" - integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= - -os-locale@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2" - integrity sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA== - dependencies: - execa "^0.7.0" - lcid "^1.0.0" - mem "^1.1.0" - -os-tmpdir@^1.0.0, os-tmpdir@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" - integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= - -osenv@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" - integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== - dependencies: - os-homedir "^1.0.0" - os-tmpdir "^1.0.0" - -p-finally@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" - integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= - -p-limit@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" - integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== - dependencies: - p-try "^1.0.0" - -p-locate@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" - integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= - dependencies: - p-limit "^1.1.0" - -p-try@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" - integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= - -parcel-plugin-bundle-visualiser@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/parcel-plugin-bundle-visualiser/-/parcel-plugin-bundle-visualiser-1.2.0.tgz#b24cde64233c8e8ce2561ec5d864a7543d8e719d" - integrity sha512-/O+26nsOwXbl1q6A/X9lEJWAPwZt5VauTV32omC3a/09bfUgHTogkAIYB/BqrGQm6OyuoG5FATToT3AGGk9RTA== - dependencies: - filesize "^3.6.0" - gzip-size "^4.1.0" - yargs "^11.0.0" - -parse-json@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" - integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA= - dependencies: - error-ex "^1.3.1" - json-parse-better-errors "^1.0.1" - -pascalcase@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" - integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= - -path-dirname@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" - integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA= - -path-exists@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" - integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= - -path-is-absolute@^1.0.0, path-is-absolute@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= - -path-key@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" - integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= - -pify@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" - integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= - -popper.js@^1.14.7: - version "1.14.7" - resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.7.tgz#e31ec06cfac6a97a53280c3e55e4e0c860e7738e" - integrity sha512-4q1hNvoUre/8srWsH7hnoSJ5xVmIL4qgz+s4qf2TnJIMyZFUFMGH+9vE7mXynAlHSZ/NdTmmow86muD0myUkVQ== - -posix-character-classes@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" - integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= - -postcss-calc@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-7.0.1.tgz#36d77bab023b0ecbb9789d84dcb23c4941145436" - integrity sha512-oXqx0m6tb4N3JGdmeMSc/i91KppbYsFZKdH0xMOqK8V1rJlzrKlTdokz8ozUXLVejydRN6u2IddxpcijRj2FqQ== - dependencies: - css-unit-converter "^1.1.1" - postcss "^7.0.5" - postcss-selector-parser "^5.0.0-rc.4" - postcss-value-parser "^3.3.1" - -postcss-colormin@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-4.0.3.tgz#ae060bce93ed794ac71264f08132d550956bd381" - integrity sha512-WyQFAdDZpExQh32j0U0feWisZ0dmOtPl44qYmJKkq9xFWY3p+4qnRzCHeNrkeRhwPHz9bQ3mo0/yVkaply0MNw== - dependencies: - browserslist "^4.0.0" - color "^3.0.0" - has "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-convert-values@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-4.0.1.tgz#ca3813ed4da0f812f9d43703584e449ebe189a7f" - integrity sha512-Kisdo1y77KUC0Jmn0OXU/COOJbzM8cImvw1ZFsBgBgMgb1iL23Zs/LXRe3r+EZqM3vGYKdQ2YJVQ5VkJI+zEJQ== - dependencies: - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-discard-comments@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-4.0.2.tgz#1fbabd2c246bff6aaad7997b2b0918f4d7af4033" - integrity sha512-RJutN259iuRf3IW7GZyLM5Sw4GLTOH8FmsXBnv8Ab/Tc2k4SR4qbV4DNbyyY4+Sjo362SyDmW2DQ7lBSChrpkg== - dependencies: - postcss "^7.0.0" - -postcss-discard-duplicates@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-4.0.2.tgz#3fe133cd3c82282e550fc9b239176a9207b784eb" - integrity sha512-ZNQfR1gPNAiXZhgENFfEglF93pciw0WxMkJeVmw8eF+JZBbMD7jp6C67GqJAXVZP2BWbOztKfbsdmMp/k8c6oQ== - dependencies: - postcss "^7.0.0" - -postcss-discard-empty@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-4.0.1.tgz#c8c951e9f73ed9428019458444a02ad90bb9f765" - integrity sha512-B9miTzbznhDjTfjvipfHoqbWKwd0Mj+/fL5s1QOz06wufguil+Xheo4XpOnc4NqKYBCNqqEzgPv2aPBIJLox0w== - dependencies: - postcss "^7.0.0" - -postcss-discard-overridden@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-4.0.1.tgz#652aef8a96726f029f5e3e00146ee7a4e755ff57" - integrity sha512-IYY2bEDD7g1XM1IDEsUT4//iEYCxAmP5oDSFMVU/JVvT7gh+l4fmjciLqGgwjdWpQIdb0Che2VX00QObS5+cTg== - dependencies: - postcss "^7.0.0" - -postcss-merge-longhand@^4.0.11: - version "4.0.11" - resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-4.0.11.tgz#62f49a13e4a0ee04e7b98f42bb16062ca2549e24" - integrity sha512-alx/zmoeXvJjp7L4mxEMjh8lxVlDFX1gqWHzaaQewwMZiVhLo42TEClKaeHbRf6J7j82ZOdTJ808RtN0ZOZwvw== - dependencies: - css-color-names "0.0.4" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - stylehacks "^4.0.0" - -postcss-merge-rules@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-4.0.3.tgz#362bea4ff5a1f98e4075a713c6cb25aefef9a650" - integrity sha512-U7e3r1SbvYzO0Jr3UT/zKBVgYYyhAz0aitvGIYOYK5CPmkNih+WDSsS5tvPrJ8YMQYlEMvsZIiqmn7HdFUaeEQ== - dependencies: - browserslist "^4.0.0" - caniuse-api "^3.0.0" - cssnano-util-same-parent "^4.0.0" - postcss "^7.0.0" - postcss-selector-parser "^3.0.0" - vendors "^1.0.0" - -postcss-minify-font-values@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-4.0.2.tgz#cd4c344cce474343fac5d82206ab2cbcb8afd5a6" - integrity sha512-j85oO6OnRU9zPf04+PZv1LYIYOprWm6IA6zkXkrJXyRveDEuQggG6tvoy8ir8ZwjLxLuGfNkCZEQG7zan+Hbtg== - dependencies: - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-minify-gradients@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-4.0.2.tgz#93b29c2ff5099c535eecda56c4aa6e665a663471" - integrity sha512-qKPfwlONdcf/AndP1U8SJ/uzIJtowHlMaSioKzebAXSG4iJthlWC9iSWznQcX4f66gIWX44RSA841HTHj3wK+Q== - dependencies: - cssnano-util-get-arguments "^4.0.0" - is-color-stop "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-minify-params@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-4.0.2.tgz#6b9cef030c11e35261f95f618c90036d680db874" - integrity sha512-G7eWyzEx0xL4/wiBBJxJOz48zAKV2WG3iZOqVhPet/9geefm/Px5uo1fzlHu+DOjT+m0Mmiz3jkQzVHe6wxAWg== - dependencies: - alphanum-sort "^1.0.0" - browserslist "^4.0.0" - cssnano-util-get-arguments "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - uniqs "^2.0.0" - -postcss-minify-selectors@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-4.0.2.tgz#e2e5eb40bfee500d0cd9243500f5f8ea4262fbd8" - integrity sha512-D5S1iViljXBj9kflQo4YutWnJmwm8VvIsU1GeXJGiG9j8CIg9zs4voPMdQDUmIxetUOh60VilsNzCiAFTOqu3g== - dependencies: - alphanum-sort "^1.0.0" - has "^1.0.0" - postcss "^7.0.0" - postcss-selector-parser "^3.0.0" - -postcss-normalize-charset@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-4.0.1.tgz#8b35add3aee83a136b0471e0d59be58a50285dd4" - integrity sha512-gMXCrrlWh6G27U0hF3vNvR3w8I1s2wOBILvA87iNXaPvSNo5uZAMYsZG7XjCUf1eVxuPfyL4TJ7++SGZLc9A3g== - dependencies: - postcss "^7.0.0" - -postcss-normalize-display-values@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.2.tgz#0dbe04a4ce9063d4667ed2be476bb830c825935a" - integrity sha512-3F2jcsaMW7+VtRMAqf/3m4cPFhPD3EFRgNs18u+k3lTJJlVe7d0YPO+bnwqo2xg8YiRpDXJI2u8A0wqJxMsQuQ== - dependencies: - cssnano-util-get-match "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-normalize-positions@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-normalize-positions/-/postcss-normalize-positions-4.0.2.tgz#05f757f84f260437378368a91f8932d4b102917f" - integrity sha512-Dlf3/9AxpxE+NF1fJxYDeggi5WwV35MXGFnnoccP/9qDtFrTArZ0D0R+iKcg5WsUd8nUYMIl8yXDCtcrT8JrdA== - dependencies: - cssnano-util-get-arguments "^4.0.0" - has "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-normalize-repeat-style@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-4.0.2.tgz#c4ebbc289f3991a028d44751cbdd11918b17910c" - integrity sha512-qvigdYYMpSuoFs3Is/f5nHdRLJN/ITA7huIoCyqqENJe9PvPmLhNLMu7QTjPdtnVf6OcYYO5SHonx4+fbJE1+Q== - dependencies: - cssnano-util-get-arguments "^4.0.0" - cssnano-util-get-match "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-normalize-string@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-normalize-string/-/postcss-normalize-string-4.0.2.tgz#cd44c40ab07a0c7a36dc5e99aace1eca4ec2690c" - integrity sha512-RrERod97Dnwqq49WNz8qo66ps0swYZDSb6rM57kN2J+aoyEAJfZ6bMx0sx/F9TIEX0xthPGCmeyiam/jXif0eA== - dependencies: - has "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-normalize-timing-functions@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-4.0.2.tgz#8e009ca2a3949cdaf8ad23e6b6ab99cb5e7d28d9" - integrity sha512-acwJY95edP762e++00Ehq9L4sZCEcOPyaHwoaFOhIwWCDfik6YvqsYNxckee65JHLKzuNSSmAdxwD2Cud1Z54A== - dependencies: - cssnano-util-get-match "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-normalize-unicode@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-unicode/-/postcss-normalize-unicode-4.0.1.tgz#841bd48fdcf3019ad4baa7493a3d363b52ae1cfb" - integrity sha512-od18Uq2wCYn+vZ/qCOeutvHjB5jm57ToxRaMeNuf0nWVHaP9Hua56QyMF6fs/4FSUnVIw0CBPsU0K4LnBPwYwg== - dependencies: - browserslist "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-normalize-url@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-4.0.1.tgz#10e437f86bc7c7e58f7b9652ed878daaa95faae1" - integrity sha512-p5oVaF4+IHwu7VpMan/SSpmpYxcJMtkGppYf0VbdH5B6hN8YNmVyJLuY9FmLQTzY3fag5ESUUHDqM+heid0UVA== - dependencies: - is-absolute-url "^2.0.0" - normalize-url "^3.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-normalize-whitespace@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-normalize-whitespace/-/postcss-normalize-whitespace-4.0.2.tgz#bf1d4070fe4fcea87d1348e825d8cc0c5faa7d82" - integrity sha512-tO8QIgrsI3p95r8fyqKV+ufKlSHh9hMJqACqbv2XknufqEDhDvbguXGBBqxw9nsQoXWf0qOqppziKJKHMD4GtA== - dependencies: - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-ordered-values@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-4.1.2.tgz#0cf75c820ec7d5c4d280189559e0b571ebac0eee" - integrity sha512-2fCObh5UanxvSxeXrtLtlwVThBvHn6MQcu4ksNT2tsaV2Fg76R2CV98W7wNSlX+5/pFwEyaDwKLLoEV7uRybAw== - dependencies: - cssnano-util-get-arguments "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-reduce-initial@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-4.0.3.tgz#7fd42ebea5e9c814609639e2c2e84ae270ba48df" - integrity sha512-gKWmR5aUulSjbzOfD9AlJiHCGH6AEVLaM0AV+aSioxUDd16qXP1PCh8d1/BGVvpdWn8k/HiK7n6TjeoXN1F7DA== - dependencies: - browserslist "^4.0.0" - caniuse-api "^3.0.0" - has "^1.0.0" - postcss "^7.0.0" - -postcss-reduce-transforms@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-4.0.2.tgz#17efa405eacc6e07be3414a5ca2d1074681d4e29" - integrity sha512-EEVig1Q2QJ4ELpJXMZR8Vt5DQx8/mo+dGWSR7vWXqcob2gQLyQGsionYcGKATXvQzMPn6DSN1vTN7yFximdIAg== - dependencies: - cssnano-util-get-match "^4.0.0" - has "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-selector-parser@^3.0.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz#4f875f4afb0c96573d5cf4d74011aee250a7e865" - integrity sha1-T4dfSvsMllc9XPTXQBGu4lCn6GU= - dependencies: - dot-prop "^4.1.1" - indexes-of "^1.0.1" - uniq "^1.0.1" - -postcss-selector-parser@^5.0.0, postcss-selector-parser@^5.0.0-rc.4: - version "5.0.0" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-5.0.0.tgz#249044356697b33b64f1a8f7c80922dddee7195c" - integrity sha512-w+zLE5Jhg6Liz8+rQOWEAwtwkyqpfnmsinXjXg6cY7YIONZZtgvE0v2O0uhQBs0peNomOJwWRKt6JBfTdTd3OQ== - dependencies: - cssesc "^2.0.0" - indexes-of "^1.0.1" - uniq "^1.0.1" - -postcss-svgo@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-4.0.2.tgz#17b997bc711b333bab143aaed3b8d3d6e3d38258" - integrity sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw== - dependencies: - is-svg "^3.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - svgo "^1.0.0" - -postcss-unique-selectors@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-4.0.1.tgz#9446911f3289bfd64c6d680f073c03b1f9ee4bac" - integrity sha512-+JanVaryLo9QwZjKrmJgkI4Fn8SBgRO6WXQBJi7KiAVPlmxikB5Jzc4EvXMT2H0/m0RjrVVm9rGNhZddm/8Spg== - dependencies: - alphanum-sort "^1.0.0" - postcss "^7.0.0" - uniqs "^2.0.0" - -postcss-value-parser@^3.0.0, postcss-value-parser@^3.3.1: - version "3.3.1" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" - integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== - -postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.5: - version "7.0.14" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.14.tgz#4527ed6b1ca0d82c53ce5ec1a2041c2346bbd6e5" - integrity sha512-NsbD6XUUMZvBxtQAJuWDJeeC4QFsmWsfozWxCJPWf3M55K9iu2iMDaKqyoOdTJ1R4usBXuxlVFAIo8rZPQD4Bg== - dependencies: - chalk "^2.4.2" - source-map "^0.6.1" - supports-color "^6.1.0" - -prettier@1.16.3: - version "1.16.3" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.16.3.tgz#8c62168453badef702f34b45b6ee899574a6a65d" - integrity sha512-kn/GU6SMRYPxUakNXhpP0EedT/KmaPzr0H5lIsDogrykbaxOpOfAFfk5XA7DZrJyMAv1wlMV3CPcZruGXVVUZw== - -private@^0.1.6, private@^0.1.8: - version "0.1.8" - resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" - integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg== - -process-nextick-args@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa" - integrity sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw== - -pseudomap@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" - integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= - -q@^1.1.2: - version "1.5.1" - resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" - integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= - -rc@^1.2.7: - version "1.2.8" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" - integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== - dependencies: - deep-extend "^0.6.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - -readable-stream@^2.0.2, readable-stream@^2.0.6: - version "2.3.6" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" - integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readdirp@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" - integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ== - dependencies: - graceful-fs "^4.1.11" - micromatch "^3.1.10" - readable-stream "^2.0.2" - -regenerate@^1.2.1: - version "1.4.0" - resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11" - integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg== - -regenerator-runtime@^0.11.0: - version "0.11.1" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" - integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== - -regenerator-transform@^0.10.0: - version "0.10.1" - resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.10.1.tgz#1e4996837231da8b7f3cf4114d71b5691a0680dd" - integrity sha512-PJepbvDbuK1xgIgnau7Y90cwaAmO/LCLMI2mPvaXq2heGMR3aWW5/BQvYrhJ8jgmQjXewXvBjzfqKcVOmhjZ6Q== - dependencies: - babel-runtime "^6.18.0" - babel-types "^6.19.0" - private "^0.1.6" - -regex-not@^1.0.0, regex-not@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" - integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== - dependencies: - extend-shallow "^3.0.2" - safe-regex "^1.1.0" - -regexpu-core@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-2.0.0.tgz#49d038837b8dcf8bfa5b9a42139938e6ea2ae240" - integrity sha1-SdA4g3uNz4v6W5pCE5k45uoq4kA= - dependencies: - regenerate "^1.2.1" - regjsgen "^0.2.0" - regjsparser "^0.1.4" - -regjsgen@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.2.0.tgz#6c016adeac554f75823fe37ac05b92d5a4edb1f7" - integrity sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc= - -regjsparser@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.1.5.tgz#7ee8f84dc6fa792d3fd0ae228d24bd949ead205c" - integrity sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw= - dependencies: - jsesc "~0.5.0" - -remove-trailing-separator@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" - integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= - -repeat-element@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" - integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== - -repeat-string@^1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" - integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= - -repeating@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" - integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo= - dependencies: - is-finite "^1.0.0" - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= - -require-main-filename@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" - integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE= - -resolve-from@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" - integrity sha1-six699nWiBvItuZTM17rywoYh0g= - -resolve-url@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" - integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= - -ret@~0.1.10: - version "0.1.15" - resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" - integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== - -rgb-regex@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1" - integrity sha1-wODWiC3w4jviVKR16O3UGRX+rrE= - -rgba-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" - integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM= - -rimraf@^2.6.1: - version "2.6.3" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" - integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== - dependencies: - glob "^7.1.3" - -safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - -safe-regex@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" - integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= - dependencies: - ret "~0.1.10" - -"safer-buffer@>= 2.1.2 < 3": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -sass@^1.17.3: - version "1.17.3" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.17.3.tgz#19f9164cf8653b9fca670a64e53285272c96d192" - integrity sha512-S4vJawbrNUxJUBiHLXPYUKZCoO6cvq3/3ZFBV66a+PafTxcDEFJB+FHLDFl0P+rUfha/703ajEXMuGTYhJESkQ== - dependencies: - chokidar "^2.0.0" - -sax@^1.2.4, sax@~1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" - integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== - -semver@^5.3.0: - version "5.7.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b" - integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA== - -set-blocking@^2.0.0, set-blocking@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= - -set-value@^0.4.3: - version "0.4.3" - resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1" - integrity sha1-fbCPnT0i3H945Trzw79GZuzfzPE= - dependencies: - extend-shallow "^2.0.1" - is-extendable "^0.1.1" - is-plain-object "^2.0.1" - to-object-path "^0.3.0" - -set-value@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274" - integrity sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg== - dependencies: - extend-shallow "^2.0.1" - is-extendable "^0.1.1" - is-plain-object "^2.0.3" - split-string "^3.0.1" - -shebang-command@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" - integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= - dependencies: - shebang-regex "^1.0.0" - -shebang-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" - integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= - -signal-exit@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" - integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= - -simple-swizzle@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" - integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo= - dependencies: - is-arrayish "^0.3.1" - -slash@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" - integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU= - -snapdragon-node@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" - integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== - dependencies: - define-property "^1.0.0" - isobject "^3.0.0" - snapdragon-util "^3.0.1" - -snapdragon-util@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" - integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== - dependencies: - kind-of "^3.2.0" - -snapdragon@^0.8.1: - version "0.8.2" - resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" - integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== - dependencies: - base "^0.11.1" - debug "^2.2.0" - define-property "^0.2.5" - extend-shallow "^2.0.1" - map-cache "^0.2.2" - source-map "^0.5.6" - source-map-resolve "^0.5.0" - use "^3.1.0" - -source-map-resolve@^0.5.0: - version "0.5.2" - resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259" - integrity sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA== - dependencies: - atob "^2.1.1" - decode-uri-component "^0.2.0" - resolve-url "^0.2.1" - source-map-url "^0.4.0" - urix "^0.1.0" - -source-map-support@^0.4.15: - version "0.4.18" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f" - integrity sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA== - dependencies: - source-map "^0.5.6" - -source-map-url@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" - integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= - -source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7: - version "0.5.7" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" - integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= - -source-map@^0.6.1, source-map@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - -split-string@^3.0.1, split-string@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" - integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== - dependencies: - extend-shallow "^3.0.0" - -sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= - -stable@^0.1.8: - version "0.1.8" - resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" - integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== - -static-extend@^0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" - integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= - dependencies: - define-property "^0.2.5" - object-copy "^0.1.0" - -string-width@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" - integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - strip-ansi "^3.0.0" - -"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" - integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== - dependencies: - is-fullwidth-code-point "^2.0.0" - strip-ansi "^4.0.0" - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - -strip-ansi@^3.0.0, strip-ansi@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" - integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= - dependencies: - ansi-regex "^2.0.0" - -strip-ansi@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" - integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= - dependencies: - ansi-regex "^3.0.0" - -strip-eof@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" - integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= - -strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= - -stylehacks@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5" - integrity sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g== - dependencies: - browserslist "^4.0.0" - postcss "^7.0.0" - postcss-selector-parser "^3.0.0" - -supports-color@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" - integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= - -supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -supports-color@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" - integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== - dependencies: - has-flag "^3.0.0" - -svgo@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.2.0.tgz#305a8fc0f4f9710828c65039bb93d5793225ffc3" - integrity sha512-xBfxJxfk4UeVN8asec9jNxHiv3UAMv/ujwBWGYvQhhMb2u3YTGKkiybPcLFDLq7GLLWE9wa73e0/m8L5nTzQbw== - dependencies: - chalk "^2.4.1" - coa "^2.0.2" - css-select "^2.0.0" - css-select-base-adapter "^0.1.1" - css-tree "1.0.0-alpha.28" - css-url-regex "^1.1.0" - csso "^3.5.1" - js-yaml "^3.12.0" - mkdirp "~0.5.1" - object.values "^1.1.0" - sax "~1.2.4" - stable "^0.1.8" - unquote "~1.1.1" - util.promisify "~1.0.0" - -tar@^4: - version "4.4.8" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.8.tgz#b19eec3fde2a96e64666df9fdb40c5ca1bc3747d" - integrity sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ== - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.3.4" - minizlib "^1.1.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.2" - -timsort@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" - integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= - -to-fast-properties@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47" - integrity sha1-uDVx+k2MJbguIxsG46MFXeTKGkc= - -to-object-path@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" - integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= - dependencies: - kind-of "^3.0.2" - -to-regex-range@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" - integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= - dependencies: - is-number "^3.0.0" - repeat-string "^1.6.1" - -to-regex@^3.0.1, to-regex@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" - integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== - dependencies: - define-property "^2.0.2" - extend-shallow "^3.0.2" - regex-not "^1.0.2" - safe-regex "^1.1.0" - -trim-right@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" - integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM= - -union-value@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4" - integrity sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ= - dependencies: - arr-union "^3.1.0" - get-value "^2.0.6" - is-extendable "^0.1.1" - set-value "^0.4.3" - -uniq@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" - integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8= - -uniqs@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" - integrity sha1-/+3ks2slKQaW5uFl1KWe25mOawI= - -unquote@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/unquote/-/unquote-1.1.1.tgz#8fded7324ec6e88a0ff8b905e7c098cdc086d544" - integrity sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ= - -unset-value@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" - integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= - dependencies: - has-value "^0.3.1" - isobject "^3.0.0" - -upath@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.2.tgz#3db658600edaeeccbe6db5e684d67ee8c2acd068" - integrity sha512-kXpym8nmDmlCBr7nKdIx8P2jNBa+pBpIUFRnKJ4dr8htyYGJFokkr2ZvERRtUN+9SY+JqXouNgUPtv6JQva/2Q== - -urix@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" - integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= - -use@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" - integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== - -util-deprecate@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= - -util.promisify@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.0.tgz#440f7165a459c9a16dc145eb8e72f35687097030" - integrity sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA== - dependencies: - define-properties "^1.1.2" - object.getownpropertydescriptors "^2.0.3" - -vendors@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.2.tgz#7fcb5eef9f5623b156bcea89ec37d63676f21801" - integrity sha512-w/hry/368nO21AN9QljsaIhb9ZiZtZARoVH5f3CsFbawdLdayCgKRPup7CggujvySMxx0I91NOyxdVENohprLQ== - -vue-feather-icons@^4.10.0: - version "4.10.0" - resolved "https://registry.yarnpkg.com/vue-feather-icons/-/vue-feather-icons-4.10.0.tgz#84b699fe148b709ff3f609fc94eed44b078f1c27" - integrity sha512-fmL/v7DN9HYqnkR7h16PvoMgUk41kxqgqv7yPCAcW4nXRaX1dKgnLwm8m5R2Lpu0NxwpYzRKeTvOs+ti3KaqSg== - dependencies: - babel-helper-vue-jsx-merge-props "^2.0.2" - -vue-functional-data-merge@^2.0.7: - version "2.0.7" - resolved "https://registry.yarnpkg.com/vue-functional-data-merge/-/vue-functional-data-merge-2.0.7.tgz#bdee655181eacdcb1f96ce95a4cc14e75313d1da" - integrity sha512-pvLc+H+x2prwBj/uSEIITyxjz/7ZUVVK8uYbrYMmhDvMXnzh9OvQvVEwcOSBQjsubd4Eq41/CSJaWzy4hemMNQ== - -vue-hot-reload-api@^2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.3.tgz#2756f46cb3258054c5f4723de8ae7e87302a1ccf" - integrity sha512-KmvZVtmM26BQOMK1rwUZsrqxEGeKiYSZGA7SNWE6uExx8UX/cj9hq2MRV/wWC3Cq6AoeDGk57rL9YMFRel/q+g== - -vue-router@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.2.tgz#dedc67afe6c4e2bc25682c8b1c2a8c0d7c7e56be" - integrity sha512-opKtsxjp9eOcFWdp6xLQPLmRGgfM932Tl56U9chYTnoWqKxQ8M20N7AkdEbM5beUh6wICoFGYugAX9vQjyJLFg== - -vue-template-compiler@^2.6.10: - version "2.6.10" - resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.6.10.tgz#323b4f3495f04faa3503337a82f5d6507799c9cc" - integrity sha512-jVZkw4/I/HT5ZMvRnhv78okGusqe0+qH2A0Em0Cp8aq78+NK9TII263CDVz2QXZsIT+yyV/gZc/j/vlwa+Epyg== - dependencies: - de-indent "^1.0.2" - he "^1.1.0" - -vue-template-es2015-compiler@^1.9.0: - version "1.9.1" - resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825" - integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw== - -vue@^2.6.10: - version "2.6.10" - resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637" - integrity sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ== - -which-module@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" - integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= - -which@^1.2.9: - version "1.3.1" - resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" - integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== - dependencies: - isexe "^2.0.0" - -wide-align@^1.1.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" - integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== - dependencies: - string-width "^1.0.2 || 2" - -wrap-ansi@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" - integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU= - dependencies: - string-width "^1.0.1" - strip-ansi "^3.0.1" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= - -y18n@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" - integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= - -yallist@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" - integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= - -yallist@^3.0.0, yallist@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9" - integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A== - -yargs-parser@^9.0.2: - version "9.0.2" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-9.0.2.tgz#9ccf6a43460fe4ed40a9bb68f48d43b8a68cc077" - integrity sha1-nM9qQ0YP5O1Aqbto9I1DuKaMwHc= - dependencies: - camelcase "^4.1.0" - -yargs@^11.0.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-11.1.0.tgz#90b869934ed6e871115ea2ff58b03f4724ed2d77" - integrity sha512-NwW69J42EsCSanF8kyn5upxvjp5ds+t3+udGBeTbFnERA+lF541DDpMawzo4z6W/QrzNM18D+BPMiOBibnFV5A== - dependencies: - cliui "^4.0.0" - decamelize "^1.1.1" - find-up "^2.1.0" - get-caller-file "^1.0.1" - os-locale "^2.0.0" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^2.0.0" - which-module "^2.0.0" - y18n "^3.2.1" - yargs-parser "^9.0.2" From 89402263851f6a29ca4241ce31a233ae42eeddcf Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 16:24:33 +0200 Subject: [PATCH 090/103] Clarify install instructions in the README --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index efbeedb2..2c18e9d4 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,23 @@ Running PluralKit is pretty easy with Docker. The repository contains a `docker- * Clone this repository: `git clone https://github.com/xSke/PluralKit` * Create a `pluralkit.conf` file in the same directory as `docker-compose.yml` containing at least a `PluralKit.Bot.Token` field + * (`PluralKit.Database` is overridden in `docker-compose.yml` to point to the Postgres container) * Build the bot: `docker-compose build` * Run the bot: `docker-compose up` +In other words: +``` +$ git clone https://github.com/xSke/PluralKit +$ cd PluralKit +$ cp pluralkit.conf.example pluralkit.conf +$ nano pluralkit.conf # (or vim, or whatever) +$ docker-compose up -d +``` + ## Manually * Install the .NET Core 2.2 SDK (see https://dotnet.microsoft.com/download) * Clone this repository: `git clone https://github.com/xSke/PluralKit` +* Create and fill in a `pluralkit.conf` file in the same directory as `docker-compose.yml` * Run the bot: `dotnet run --project PluralKit.Bot` From ca56fd419b3131694d0ad5022e7fc4d09146ffb5 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Wed, 10 Jul 2019 23:16:17 +0200 Subject: [PATCH 091/103] Fix various issues with proxying and webhook caching --- PluralKit.Bot/Bot.cs | 2 +- PluralKit.Bot/Services/EmbedService.cs | 2 +- PluralKit.Bot/Services/ProxyService.cs | 47 ++++++++++++++++++- PluralKit.Bot/Services/WebhookCacheService.cs | 17 +++++-- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 3d565c81..71c04a39 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -51,7 +51,7 @@ namespace PluralKit.Bot .AddTransient(_ => _config.GetSection("PluralKit").Get<CoreConfig>() ?? new CoreConfig()) .AddTransient(_ => _config.GetSection("PluralKit").GetSection("Bot").Get<BotConfig>() ?? new BotConfig()) - .AddScoped<IDbConnection>(svc => + .AddTransient<IDbConnection>(svc => { var conn = new NpgsqlConnection(svc.GetRequiredService<CoreConfig>().Database); diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 2278684e..4e3ef3bd 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -46,7 +46,7 @@ namespace PluralKit.Bot { return new EmbedBuilder() .WithAuthor($"#{message.Channel.Name}: {member.Name}", member.AvatarUrl) .WithDescription(message.Content) - .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: {sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: ${message.Id}") + .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: {sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: {message.Id}") .WithTimestamp(message.Timestamp) .Build(); } diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index 67178d59..083461a4 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -77,12 +77,19 @@ namespace PluralKit.Bot } public async Task HandleMessageAsync(IMessage message) { - var results = await _connection.QueryAsync<PKMember, PKSystem, ProxyDatabaseResult>("select members.*, systems.* from members, systems, accounts where members.system = systems.id and accounts.system = systems.id and accounts.uid = @Uid", (member, system) => new ProxyDatabaseResult { Member = member, System = system }, new { Uid = message.Author.Id }); + var results = await _connection.QueryAsync<PKMember, PKSystem, ProxyDatabaseResult>( + "select members.*, systems.* from members, systems, accounts where members.system = systems.id and accounts.system = systems.id and accounts.uid = @Uid", + (member, system) => + new ProxyDatabaseResult { Member = member, System = system }, new { Uid = message.Author.Id }); // Find a member with proxy tags matching the message var match = GetProxyTagMatch(message.Content, results); if (match == null) return; + // We know message.Channel can only be ITextChannel as PK doesn't work in DMs/groups + // Afterwards we ensure the bot has the right permissions, otherwise bail early + if (!await EnsureBotPermissions(message.Channel as ITextChannel)) return; + // Fetch a webhook for this channel, and send the proxied message var webhook = await _webhookCache.GetWebhook(message.Channel as ITextChannel); var hookMessage = await ExecuteWebhook(webhook, match.InnerText, match.ProxyName, match.Member.AvatarUrl, message.Attachments.FirstOrDefault()); @@ -96,8 +103,42 @@ namespace PluralKit.Bot await message.DeleteAsync(); } + private async Task<bool> EnsureBotPermissions(ITextChannel channel) + { + var guildUser = await channel.Guild.GetCurrentUserAsync(); + var permissions = guildUser.GetPermissions(channel); + + if (!permissions.ManageWebhooks) + { + await channel.SendMessageAsync( + $"{Emojis.Error} PluralKit does not have the *Manage Webhooks* permission in this channel, and thus cannot proxy messages. Please contact a server administrator to remedy this."); + return false; + } + + if (!permissions.ManageMessages) + { + await channel.SendMessageAsync( + $"{Emojis.Error} PluralKit does not have the *Manage Messages* permission in this channel, and thus cannot delete the original trigger message. Please contact a server administrator to remedy this."); + return false; + } + + return true; + } + private async Task<IMessage> ExecuteWebhook(IWebhook webhook, string text, string username, string avatarUrl, IAttachment attachment) { - var client = new DiscordWebhookClient(webhook); + // TODO: DiscordWebhookClient's ctor does a call to GetWebhook that may be unnecessary, see if there's a way to do this The Hard Way :tm: + // TODO: this will probably crash if there are multiple consecutive failures, perhaps have a loop instead? + DiscordWebhookClient client; + try + { + client = new DiscordWebhookClient(webhook); + } + catch (InvalidOperationException) + { + // webhook was deleted or invalid + webhook = await _webhookCache.InvalidateAndRefreshWebhook(webhook); + client = new DiscordWebhookClient(webhook); + } ulong messageId; if (attachment != null) { @@ -108,6 +149,8 @@ namespace PluralKit.Bot } else { messageId = await client.SendMessageAsync(text, username: username, avatarUrl: avatarUrl); } + + // TODO: SendMessageAsync should return a full object(??), see if there's a way to avoid the extra server call here return await webhook.Channel.GetMessageAsync(messageId); } diff --git a/PluralKit.Bot/Services/WebhookCacheService.cs b/PluralKit.Bot/Services/WebhookCacheService.cs index e45597d0..8ea52dc7 100644 --- a/PluralKit.Bot/Services/WebhookCacheService.cs +++ b/PluralKit.Bot/Services/WebhookCacheService.cs @@ -32,13 +32,24 @@ namespace PluralKit.Bot // We cache the webhook through a Lazy<Task<T>>, this way we make sure to only create one webhook per channel // If the webhook is requested twice before it's actually been found, the Lazy<T> wrapper will stop the // webhook from being created twice. - var lazyWebhookValue = + var lazyWebhookValue = _webhooks.GetOrAdd(channel.Id, new Lazy<Task<IWebhook>>(() => GetOrCreateWebhook(channel))); - return await lazyWebhookValue.Value; + + // It's possible to "move" a webhook to a different channel after creation + // Here, we ensure it's actually still pointing towards the proper channel, and if not, wipe and refetch one. + var webhook = await lazyWebhookValue.Value; + if (webhook.Channel.Id != channel.Id) return await InvalidateAndRefreshWebhook(webhook); + return webhook; + } + + public async Task<IWebhook> InvalidateAndRefreshWebhook(IWebhook webhook) + { + _webhooks.TryRemove(webhook.Channel.Id, out _); + return await GetWebhook(webhook.Channel.Id); } private async Task<IWebhook> GetOrCreateWebhook(ITextChannel channel) => - await FindExistingWebhook(channel) ?? await GetOrCreateWebhook(channel); + await FindExistingWebhook(channel) ?? await DoCreateWebhook(channel); private async Task<IWebhook> FindExistingWebhook(ITextChannel channel) => (await channel.GetWebhooksAsync()).FirstOrDefault(IsWebhookMine); From d829630a35f25c46656db864b74dd940e589888b Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Thu, 11 Jul 2019 21:25:23 +0200 Subject: [PATCH 092/103] Fix database connection pool contention (maybe) Instead of acquiring a connection per service per request, we acquire connections more often but at a more granular level, meaning they're also disposed of more quickly instead of staying for a long time in case of long-running commands or leaks. --- PluralKit.API/Controllers/SystemController.cs | 20 ++- PluralKit.API/Startup.cs | 13 +- PluralKit.Bot/Bot.cs | 20 +-- PluralKit.Bot/Services/LogChannelService.cs | 24 ++- PluralKit.Bot/Services/ProxyService.cs | 21 ++- PluralKit.Bot/Utils.cs | 4 +- PluralKit.Core/Stores.cs | 158 +++++++++++------- PluralKit.Core/Utils.cs | 15 ++ 8 files changed, 166 insertions(+), 109 deletions(-) diff --git a/PluralKit.API/Controllers/SystemController.cs b/PluralKit.API/Controllers/SystemController.cs index 9024509c..4108e352 100644 --- a/PluralKit.API/Controllers/SystemController.cs +++ b/PluralKit.API/Controllers/SystemController.cs @@ -36,10 +36,10 @@ namespace PluralKit.API.Controllers private SystemStore _systems; private MemberStore _members; private SwitchStore _switches; - private IDbConnection _conn; + private DbConnectionFactory _conn; private TokenAuthService _auth; - public SystemController(SystemStore systems, MemberStore members, SwitchStore switches, IDbConnection conn, TokenAuthService auth) + public SystemController(SystemStore systems, MemberStore members, SwitchStore switches, DbConnectionFactory conn, TokenAuthService auth) { _systems = systems; _members = members; @@ -74,15 +74,18 @@ namespace PluralKit.API.Controllers var system = await _systems.GetByHid(hid); if (system == null) return NotFound("System not found."); - var res = await _conn.QueryAsync<SwitchesReturn>( - @"select *, array( + using (var conn = _conn.Obtain()) + { + var res = await conn.QueryAsync<SwitchesReturn>( + @"select *, array( select members.hid from switch_members, members where switch_members.switch = switches.id and members.id = switch_members.member ) as members from switches where switches.system = @System and switches.timestamp < @Before order by switches.timestamp desc - limit 100;", new { System = system.Id, Before = before }); - return Ok(res); + limit 100;", new {System = system.Id, Before = before}); + return Ok(res); + } } [HttpGet("{hid}/fronters")] @@ -142,7 +145,10 @@ namespace PluralKit.API.Controllers return BadRequest("New members identical to existing fronters."); // Resolve member objects for all given IDs - var membersList = (await _conn.QueryAsync<PKMember>("select * from members where hid = any(@Hids)", new {Hids = param.Members})).ToList(); + IEnumerable<PKMember> membersList; + using (var conn = _conn.Obtain()) + membersList = (await conn.QueryAsync<PKMember>("select * from members where hid = any(@Hids)", new {Hids = param.Members})).ToList(); + foreach (var member in membersList) if (member.System != _auth.CurrentSystem.Id) return BadRequest($"Cannot switch to member '{member.Hid}' not in system."); diff --git a/PluralKit.API/Startup.cs b/PluralKit.API/Startup.cs index 077bde80..ff452f02 100644 --- a/PluralKit.API/Startup.cs +++ b/PluralKit.API/Startup.cs @@ -33,22 +33,17 @@ namespace PluralKit.API services.AddMvc(opts => { }) .SetCompatibilityVersion(CompatibilityVersion.Version_2_2) .AddJsonOptions(opts => { opts.SerializerSettings.BuildSerializerSettings(); }); - + services .AddTransient<SystemStore>() .AddTransient<MemberStore>() .AddTransient<SwitchStore>() .AddTransient<MessageStore>() - + .AddScoped<TokenAuthService>() - + .AddTransient(_ => Configuration.GetSection("PluralKit").Get<CoreConfig>() ?? new CoreConfig()) - .AddScoped<IDbConnection>(svc => - { - var conn = new NpgsqlConnection(svc.GetRequiredService<CoreConfig>().Database); - conn.Open(); - return conn; - }); + .AddSingleton(svc => new DbConnectionFactory(svc.GetRequiredService<CoreConfig>().Database)); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 71c04a39..e64c5373 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -1,5 +1,6 @@ using System; using System.Data; +using System.Data.Common; using System.Diagnostics; using System.IO; using System.Linq; @@ -32,8 +33,8 @@ namespace PluralKit.Bot using (var services = BuildServiceProvider()) { Console.WriteLine("- Connecting to database..."); - var connection = services.GetRequiredService<IDbConnection>() as NpgsqlConnection; - await Schema.CreateTables(connection); + using (var conn = services.GetRequiredService<DbConnectionFactory>().Obtain()) + await Schema.CreateTables(conn); Console.WriteLine("- Connecting to Discord..."); var client = services.GetRequiredService<IDiscordClient>() as DiscordSocketClient; @@ -51,13 +52,7 @@ namespace PluralKit.Bot .AddTransient(_ => _config.GetSection("PluralKit").Get<CoreConfig>() ?? new CoreConfig()) .AddTransient(_ => _config.GetSection("PluralKit").GetSection("Bot").Get<BotConfig>() ?? new BotConfig()) - .AddTransient<IDbConnection>(svc => - { - - var conn = new NpgsqlConnection(svc.GetRequiredService<CoreConfig>().Database); - conn.Open(); - return conn; - }) + .AddTransient(svc => new DbConnectionFactory(svc.GetRequiredService<CoreConfig>().Database)) .AddSingleton<IDiscordClient, DiscordSocketClient>() .AddSingleton<Bot>() @@ -170,9 +165,10 @@ namespace PluralKit.Bot // If it does, fetch the sender's system (because most commands need that) into the context, // and start command execution // Note system may be null if user has no system, hence `OrDefault` - var connection = serviceScope.ServiceProvider.GetService<IDbConnection>(); - var system = await connection.QueryFirstOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id }); - await _commands.ExecuteAsync(new PKCommandContext(_client, arg, connection, system), argPos, serviceScope.ServiceProvider); + PKSystem system; + using (var conn = serviceScope.ServiceProvider.GetService<DbConnectionFactory>().Obtain()) + system = await conn.QueryFirstOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id }); + await _commands.ExecuteAsync(new PKCommandContext(_client, arg, system), argPos, serviceScope.ServiceProvider); } else { diff --git a/PluralKit.Bot/Services/LogChannelService.cs b/PluralKit.Bot/Services/LogChannelService.cs index 93ccb638..6915c526 100644 --- a/PluralKit.Bot/Services/LogChannelService.cs +++ b/PluralKit.Bot/Services/LogChannelService.cs @@ -11,13 +11,13 @@ namespace PluralKit.Bot { public class LogChannelService { private IDiscordClient _client; - private IDbConnection _connection; + private DbConnectionFactory _conn; private EmbedService _embed; - public LogChannelService(IDiscordClient client, IDbConnection connection, EmbedService embed) + public LogChannelService(IDiscordClient client, DbConnectionFactory conn, EmbedService embed) { this._client = client; - this._connection = connection; + this._conn = conn; this._embed = embed; } @@ -30,9 +30,14 @@ namespace PluralKit.Bot { } public async Task<ITextChannel> GetLogChannel(IGuild guild) { - var server = await _connection.QueryFirstOrDefaultAsync<ServerDefinition>("select * from servers where id = @Id", new { Id = guild.Id }); - if (server?.LogChannel == null) return null; - return await _client.GetChannelAsync(server.LogChannel.Value) as ITextChannel; + using (var conn = _conn.Obtain()) + { + var server = + await conn.QueryFirstOrDefaultAsync<ServerDefinition>("select * from servers where id = @Id", + new {Id = guild.Id}); + if (server?.LogChannel == null) return null; + return await _client.GetChannelAsync(server.LogChannel.Value) as ITextChannel; + } } public async Task SetLogChannel(IGuild guild, ITextChannel newLogChannel) { @@ -41,7 +46,12 @@ namespace PluralKit.Bot { LogChannel = newLogChannel?.Id }; - await _connection.QueryAsync("insert into servers (id, log_channel) values (@Id, @LogChannel) on conflict (id) do update set log_channel = @LogChannel", def); + using (var conn = _conn.Obtain()) + { + await conn.QueryAsync( + "insert into servers (id, log_channel) values (@Id, @LogChannel) on conflict (id) do update set log_channel = @LogChannel", + def); + } } } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index 083461a4..17a61c1d 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -28,17 +28,17 @@ namespace PluralKit.Bot class ProxyService { private IDiscordClient _client; - private IDbConnection _connection; + private DbConnectionFactory _conn; private LogChannelService _logger; private WebhookCacheService _webhookCache; private MessageStore _messageStorage; private EmbedService _embeds; - public ProxyService(IDiscordClient client, WebhookCacheService webhookCache, IDbConnection connection, LogChannelService logger, MessageStore messageStorage, EmbedService embeds) + public ProxyService(IDiscordClient client, WebhookCacheService webhookCache, DbConnectionFactory conn, LogChannelService logger, MessageStore messageStorage, EmbedService embeds) { _client = client; _webhookCache = webhookCache; - _connection = connection; + _conn = conn; _logger = logger; _messageStorage = messageStorage; _embeds = embeds; @@ -76,11 +76,16 @@ namespace PluralKit.Bot return null; } - public async Task HandleMessageAsync(IMessage message) { - var results = await _connection.QueryAsync<PKMember, PKSystem, ProxyDatabaseResult>( - "select members.*, systems.* from members, systems, accounts where members.system = systems.id and accounts.system = systems.id and accounts.uid = @Uid", - (member, system) => - new ProxyDatabaseResult { Member = member, System = system }, new { Uid = message.Author.Id }); + public async Task HandleMessageAsync(IMessage message) + { + IEnumerable<ProxyDatabaseResult> results; + using (var conn = _conn.Obtain()) + { + results = await conn.QueryAsync<PKMember, PKSystem, ProxyDatabaseResult>( + "select members.*, systems.* from members, systems, accounts where members.system = systems.id and accounts.system = systems.id and accounts.uid = @Uid", + (member, system) => + new ProxyDatabaseResult {Member = member, System = system}, new {Uid = message.Author.Id}); + } // Find a member with proxy tags matching the message var match = GetProxyTagMatch(message.Content, results); diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index 341541b6..c2ef2def 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -152,14 +152,12 @@ namespace PluralKit.Bot /// Subclass of ICommandContext with PK-specific additional fields and functionality public class PKCommandContext : SocketCommandContext { - public IDbConnection Connection { get; } public PKSystem SenderSystem { get; } private object _entity; - public PKCommandContext(DiscordSocketClient client, SocketUserMessage msg, IDbConnection connection, PKSystem system) : base(client, msg) + public PKCommandContext(DiscordSocketClient client, SocketUserMessage msg, PKSystem system) : base(client, msg) { - Connection = connection; SenderSystem = system; } diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index 012794fa..084f1b6b 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -10,81 +10,96 @@ using NodaTime; namespace PluralKit { public class SystemStore { - private IDbConnection conn; + private DbConnectionFactory _conn; - public SystemStore(IDbConnection conn) { - this.conn = conn; + public SystemStore(DbConnectionFactory conn) { + this._conn = conn; } public async Task<PKSystem> Create(string systemName = null) { // TODO: handle HID collision case var hid = Utils.GenerateHid(); - return await conn.QuerySingleAsync<PKSystem>("insert into systems (hid, name) values (@Hid, @Name) returning *", new { Hid = hid, Name = systemName }); + + using (var conn = _conn.Obtain()) + 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 }); + using (var conn = _conn.Obtain()) + await conn.ExecuteAsync("insert into accounts (uid, system) values (@Id, @SystemId)", new { Id = accountId, SystemId = system.Id }); } public async Task Unlink(PKSystem system, ulong accountId) { - await conn.ExecuteAsync("delete from accounts where uid = @Id and system = @SystemId", new { Id = accountId, SystemId = system.Id }); + using (var conn = _conn.Obtain()) + await conn.ExecuteAsync("delete from accounts where uid = @Id and system = @SystemId", new { Id = accountId, SystemId = system.Id }); } public async Task<PKSystem> GetByAccount(ulong accountId) { - return await conn.QuerySingleOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.system = systems.id and accounts.uid = @Id", new { Id = accountId }); + using (var conn = _conn.Obtain()) + return await conn.QuerySingleOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.system = systems.id and accounts.uid = @Id", new { Id = accountId }); } public async Task<PKSystem> GetByHid(string hid) { - return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where systems.hid = @Hid", new { Hid = hid.ToLower() }); + using (var conn = _conn.Obtain()) + return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where systems.hid = @Hid", new { Hid = hid.ToLower() }); } public async Task<PKSystem> GetByToken(string token) { - return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where token = @Token", new { Token = token }); + using (var conn = _conn.Obtain()) + return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where token = @Token", new { Token = token }); } public async Task<PKSystem> GetById(int id) { - return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where id = @Id", new { Id = id }); + using (var conn = _conn.Obtain()) + return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where id = @Id", new { Id = id }); } public async Task Save(PKSystem system) { - await conn.ExecuteAsync("update systems set name = @Name, description = @Description, tag = @Tag, avatar_url = @AvatarUrl, token = @Token, ui_tz = @UiTz where id = @Id", system); + using (var conn = _conn.Obtain()) + await conn.ExecuteAsync("update systems set name = @Name, description = @Description, tag = @Tag, avatar_url = @AvatarUrl, token = @Token, ui_tz = @UiTz where id = @Id", system); } public async Task Delete(PKSystem system) { - await conn.ExecuteAsync("delete from systems where id = @Id", system); + using (var conn = _conn.Obtain()) + await conn.ExecuteAsync("delete from systems where id = @Id", 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 }); + using (var conn = _conn.Obtain()) + return await conn.QueryAsync<ulong>("select uid from accounts where system = @Id", new { Id = system.Id }); } } public class MemberStore { - private IDbConnection conn; + private DbConnectionFactory _conn; - public MemberStore(IDbConnection conn) { - this.conn = conn; + public MemberStore(DbConnectionFactory conn) { + this._conn = conn; } public async Task<PKMember> Create(PKSystem system, string name) { // TODO: handle collision var hid = Utils.GenerateHid(); - return await conn.QuerySingleAsync<PKMember>("insert into members (hid, system, name) values (@Hid, @SystemId, @Name) returning *", new { - Hid = hid, - SystemID = system.Id, - Name = name - }); + + using (var conn = _conn.Obtain()) + return await conn.QuerySingleAsync<PKMember>("insert into members (hid, system, name) values (@Hid, @SystemId, @Name) returning *", new { + Hid = hid, + SystemID = system.Id, + Name = name + }); } public async Task<PKMember> GetByHid(string hid) { - return await conn.QuerySingleOrDefaultAsync<PKMember>("select * from members where hid = @Hid", new { Hid = hid.ToLower() }); + using (var conn = _conn.Obtain()) + return await conn.QuerySingleOrDefaultAsync<PKMember>("select * from members where hid = @Hid", new { Hid = hid.ToLower() }); } public async Task<PKMember> GetByName(PKSystem system, string name) { // QueryFirst, since members can (in rare cases) share names - return await conn.QueryFirstOrDefaultAsync<PKMember>("select * from members where lower(name) = lower(@Name) and system = @SystemID", new { Name = name, SystemID = system.Id }); + using (var conn = _conn.Obtain()) + return await conn.QueryFirstOrDefaultAsync<PKMember>("select * from members where lower(name) = lower(@Name) and system = @SystemID", new { Name = name, SystemID = system.Id }); } public async Task<ICollection<PKMember>> GetUnproxyableMembers(PKSystem system) { @@ -96,20 +111,24 @@ namespace PluralKit { } public async Task<IEnumerable<PKMember>> GetBySystem(PKSystem system) { - return await conn.QueryAsync<PKMember>("select * from members where system = @SystemID", new { SystemID = system.Id }); + using (var conn = _conn.Obtain()) + return await conn.QueryAsync<PKMember>("select * from members where system = @SystemID", new { SystemID = system.Id }); } public async Task Save(PKMember member) { - await conn.ExecuteAsync("update members set name = @Name, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, prefix = @Prefix, suffix = @Suffix where id = @Id", member); + using (var conn = _conn.Obtain()) + await conn.ExecuteAsync("update members set name = @Name, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, prefix = @Prefix, suffix = @Suffix where id = @Id", member); } public async Task Delete(PKMember member) { - await conn.ExecuteAsync("delete from members where id = @Id", member); + using (var conn = _conn.Obtain()) + await conn.ExecuteAsync("delete from members where id = @Id", member); } public async Task<int> MessageCount(PKMember member) { - return await conn.QuerySingleAsync<int>("select count(*) from messages where member = @Id", member); + using (var conn = _conn.Obtain()) + return await conn.QuerySingleAsync<int>("select count(*) from messages where member = @Id", member); } } @@ -127,59 +146,63 @@ namespace PluralKit { public PKSystem System; } - private IDbConnection _connection; + private DbConnectionFactory _conn; - public MessageStore(IDbConnection connection) { - this._connection = connection; + public MessageStore(DbConnectionFactory conn) { + this._conn = conn; } public async Task Store(ulong senderId, ulong messageId, ulong channelId, PKMember member) { - await _connection.ExecuteAsync("insert into messages(mid, channel, member, sender) values(@MessageId, @ChannelId, @MemberId, @SenderId)", new { - MessageId = messageId, - ChannelId = channelId, - MemberId = member.Id, - SenderId = senderId - }); + using (var conn = _conn.Obtain()) + await conn.ExecuteAsync("insert into messages(mid, channel, member, sender) values(@MessageId, @ChannelId, @MemberId, @SenderId)", new { + MessageId = messageId, + ChannelId = channelId, + MemberId = member.Id, + SenderId = senderId + }); } public async Task<StoredMessage> Get(ulong id) { - return (await _connection.QueryAsync<PKMessage, PKMember, PKSystem, StoredMessage>("select messages.*, members.*, systems.* from messages, members, systems where mid = @Id and messages.member = members.id and systems.id = members.system", (msg, member, system) => new StoredMessage - { - Message = msg, - System = system, - Member = member - }, new { Id = id })).FirstOrDefault(); + using (var conn = _conn.Obtain()) + return (await conn.QueryAsync<PKMessage, PKMember, PKSystem, StoredMessage>("select messages.*, members.*, systems.* from messages, members, systems where mid = @Id and messages.member = members.id and systems.id = members.system", (msg, member, system) => new StoredMessage + { + Message = msg, + System = system, + Member = member + }, new { Id = id })).FirstOrDefault(); } public async Task Delete(ulong id) { - await _connection.ExecuteAsync("delete from messages where mid = @Id", new { Id = id }); + using (var conn = _conn.Obtain()) + await conn.ExecuteAsync("delete from messages where mid = @Id", new { Id = id }); } } public class SwitchStore { - private IDbConnection _connection; + private DbConnectionFactory _conn; - public SwitchStore(IDbConnection connection) + public SwitchStore(DbConnectionFactory conn) { - _connection = connection; + _conn = conn; } public async Task RegisterSwitch(PKSystem system, IEnumerable<PKMember> members) { // Use a transaction here since we're doing multiple executed commands in one - using (var tx = _connection.BeginTransaction()) + using (var conn = _conn.Obtain()) + using (var tx = conn.BeginTransaction()) { // First, we insert the switch itself - var sw = await _connection.QuerySingleAsync<PKSwitch>("insert into switches(system) values (@System) returning *", + var sw = await conn.QuerySingleAsync<PKSwitch>("insert into switches(system) values (@System) returning *", new {System = system.Id}); // Then we insert each member in the switch in the switch_members table // TODO: can we parallelize this or send it in bulk somehow? foreach (var member in members) { - await _connection.ExecuteAsync( + await conn.ExecuteAsync( "insert into switch_members(switch, member) values(@Switch, @Member)", new {Switch = sw.Id, Member = member.Id}); } @@ -193,33 +216,38 @@ namespace PluralKit { { // TODO: refactor the PKSwitch data structure to somehow include a hydrated member list // (maybe when we get caching in?) - return await _connection.QueryAsync<PKSwitch>("select * from switches where system = @System order by timestamp desc limit @Count", new {System = system.Id, Count = count}); + using (var conn = _conn.Obtain()) + return await conn.QueryAsync<PKSwitch>("select * from switches where system = @System order by timestamp desc limit @Count", new {System = system.Id, Count = count}); } public async Task<IEnumerable<int>> GetSwitchMemberIds(PKSwitch sw) { - return await _connection.QueryAsync<int>("select member from switch_members where switch = @Switch", - new {Switch = sw.Id}); + using (var conn = _conn.Obtain()) + return await conn.QueryAsync<int>("select member from switch_members where switch = @Switch", + new {Switch = sw.Id}); } public async Task<IEnumerable<PKMember>> GetSwitchMembers(PKSwitch sw) { - return await _connection.QueryAsync<PKMember>( - "select * from switch_members, members where switch_members.member = members.id and switch_members.switch = @Switch", - new {Switch = sw.Id}); + using (var conn = _conn.Obtain()) + return await conn.QueryAsync<PKMember>( + "select * from switch_members, members where switch_members.member = members.id and switch_members.switch = @Switch", + new {Switch = sw.Id}); } public async Task<PKSwitch> GetLatestSwitch(PKSystem system) => (await GetSwitches(system, 1)).FirstOrDefault(); public async Task MoveSwitch(PKSwitch sw, Instant time) { - await _connection.ExecuteAsync("update switches set timestamp = @Time where id = @Id", - new {Time = time, Id = sw.Id}); + using (var conn = _conn.Obtain()) + await conn.ExecuteAsync("update switches set timestamp = @Time where id = @Id", + new {Time = time, Id = sw.Id}); } public async Task DeleteSwitch(PKSwitch sw) { - await _connection.ExecuteAsync("delete from switches where id = @Id", new {Id = sw.Id}); + using (var conn = _conn.Obtain()) + await conn.ExecuteAsync("delete from switches where id = @Id", new {Id = sw.Id}); } public struct SwitchListEntry @@ -242,11 +270,15 @@ namespace PluralKit { // query DB for all members involved in any of the switches above and collect into a dictionary for future use // this makes sure the return list has the same instances of PKMember throughout, which is important for the dictionary // key used in GetPerMemberSwitchDuration below - var memberObjects = (await _connection.QueryAsync<PKMember>( - "select distinct members.* from members, switch_members where switch_members.switch = any(@Switches) and switch_members.member = members.id", // lol postgres specific `= any()` syntax - new {Switches = switchesInRange.Select(sw => sw.Id).ToList()})) - .ToDictionary(m => m.Id); - + Dictionary<int, PKMember> memberObjects; + using (var conn = _conn.Obtain()) + { + memberObjects = (await conn.QueryAsync<PKMember>( + "select distinct members.* from members, switch_members where switch_members.switch = any(@Switches) and switch_members.member = members.id", // lol postgres specific `= any()` syntax + new {Switches = switchesInRange.Select(sw => sw.Id).ToList()})) + .ToDictionary(m => m.Id); + } + // we create the entry objects var outList = new List<SwitchListEntry>(); diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index 2e49620a..f50cf248 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -333,4 +333,19 @@ namespace PluralKit return (T) value; } } + + public class DbConnectionFactory + { + private string _connectionString; + + public DbConnectionFactory(string connectionString) + { + _connectionString = connectionString; + } + + public IDbConnection Obtain() + { + return new NpgsqlConnection(_connectionString); + } + } } From c6905f4ca18aad07c50c016a6344a4264e2e00fd Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Thu, 11 Jul 2019 22:34:38 +0200 Subject: [PATCH 093/103] Allow single quotes in command arguments --- PluralKit.Bot/Bot.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index e64c5373..f9f76270 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Data; using System.Data.Common; using System.Diagnostics; @@ -57,7 +58,19 @@ namespace PluralKit.Bot .AddSingleton<IDiscordClient, DiscordSocketClient>() .AddSingleton<Bot>() - .AddTransient<CommandService>() + .AddTransient<CommandService>(_ => new CommandService(new CommandServiceConfig + { + CaseSensitiveCommands = false, + QuotationMarkAliasMap = new Dictionary<char, char> + { + {'"', '"'}, + {'\'', '\''}, + {'‘', '’'}, + {'“', '”'}, + {'„', '‟'}, + }, + DefaultRunMode = RunMode.Async + })) .AddTransient<EmbedService>() .AddTransient<ProxyService>() .AddTransient<LogChannelService>() From a41e20a0a3f32431355b649ee4ed16d138128378 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Thu, 11 Jul 2019 22:46:18 +0200 Subject: [PATCH 094/103] Fix importing with no existing system --- PluralKit.Bot/Commands/ImportExportCommands.cs | 2 +- PluralKit.Core/DataFiles.cs | 8 +++++++- PluralKit.Core/Stores.cs | 4 +++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/PluralKit.Bot/Commands/ImportExportCommands.cs b/PluralKit.Bot/Commands/ImportExportCommands.cs index 69b31d6d..28123248 100644 --- a/PluralKit.Bot/Commands/ImportExportCommands.cs +++ b/PluralKit.Bot/Commands/ImportExportCommands.cs @@ -85,7 +85,7 @@ namespace PluralKit.Bot.Commands // If passed system is null, it'll create a new one // (and that's okay!) - var result = await DataFiles.ImportSystem(data, Context.SenderSystem); + var result = await DataFiles.ImportSystem(data, Context.SenderSystem, Context.User.Id); if (Context.SenderSystem == null) { diff --git a/PluralKit.Core/DataFiles.cs b/PluralKit.Core/DataFiles.cs index 1b314c6b..0e37342a 100644 --- a/PluralKit.Core/DataFiles.cs +++ b/PluralKit.Core/DataFiles.cs @@ -64,8 +64,11 @@ namespace PluralKit.Bot Timestamp = Formats.TimestampExportFormat.Format(sw.Timestamp) }; - public async Task<ImportResult> ImportSystem(DataFileSystem data, PKSystem system) + public async Task<ImportResult> ImportSystem(DataFileSystem data, PKSystem system, ulong accountId) { + // TODO: make atomic, somehow - we'd need to obtain one IDbConnection and reuse it + // which probably means refactoring SystemStore.Save and friends etc + var result = new ImportResult {AddedNames = new List<string>(), ModifiedNames = new List<string>()}; // If we don't already have a system to save to, create one @@ -78,6 +81,9 @@ namespace PluralKit.Bot if (data.AvatarUrl != null) system.AvatarUrl = data.AvatarUrl; if (data.TimeZone != null) system.UiTz = data.TimeZone ?? "UTC"; await _systems.Save(system); + + // Make sure to link the sender account, too + await _systems.Link(system, accountId); // Apply members // TODO: parallelize? diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index 084f1b6b..6ed9f466 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -25,8 +25,10 @@ namespace PluralKit { } public async Task Link(PKSystem system, ulong accountId) { + // We have "on conflict do nothing" since linking an account when it's already linked to the same system is idempotent + // This is used in import/export, although the pk;link command checks for this case beforehand using (var conn = _conn.Obtain()) - await conn.ExecuteAsync("insert into accounts (uid, system) values (@Id, @SystemId)", new { Id = accountId, SystemId = system.Id }); + await conn.ExecuteAsync("insert into accounts (uid, system) values (@Id, @SystemId) on conflict do nothing", new { Id = accountId, SystemId = system.Id }); } public async Task Unlink(PKSystem system, ulong accountId) { From ebc311ecc3ef02665721ecdd66fdc6ead76d38f7 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sun, 14 Jul 2019 05:23:27 +0200 Subject: [PATCH 095/103] Remove message query reaction AND open DB connection when obtaining one --- PluralKit.API/Controllers/SystemController.cs | 4 +- PluralKit.Bot/Bot.cs | 4 +- PluralKit.Bot/Services/LogChannelService.cs | 4 +- PluralKit.Bot/Services/ProxyService.cs | 14 +++-- PluralKit.Bot/Utils.cs | 19 +++++++ PluralKit.Core/Stores.cs | 54 +++++++++---------- PluralKit.Core/Utils.cs | 7 ++- 7 files changed, 68 insertions(+), 38 deletions(-) diff --git a/PluralKit.API/Controllers/SystemController.cs b/PluralKit.API/Controllers/SystemController.cs index 4108e352..f5fadfff 100644 --- a/PluralKit.API/Controllers/SystemController.cs +++ b/PluralKit.API/Controllers/SystemController.cs @@ -74,7 +74,7 @@ namespace PluralKit.API.Controllers var system = await _systems.GetByHid(hid); if (system == null) return NotFound("System not found."); - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) { var res = await conn.QueryAsync<SwitchesReturn>( @"select *, array( @@ -146,7 +146,7 @@ namespace PluralKit.API.Controllers // Resolve member objects for all given IDs IEnumerable<PKMember> membersList; - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) membersList = (await conn.QueryAsync<PKMember>("select * from members where hid = any(@Hids)", new {Hids = param.Members})).ToList(); foreach (var member in membersList) diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index f9f76270..a51d77ea 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -34,7 +34,7 @@ namespace PluralKit.Bot using (var services = BuildServiceProvider()) { Console.WriteLine("- Connecting to database..."); - using (var conn = services.GetRequiredService<DbConnectionFactory>().Obtain()) + using (var conn = await services.GetRequiredService<DbConnectionFactory>().Obtain()) await Schema.CreateTables(conn); Console.WriteLine("- Connecting to Discord..."); @@ -179,7 +179,7 @@ namespace PluralKit.Bot // and start command execution // Note system may be null if user has no system, hence `OrDefault` PKSystem system; - using (var conn = serviceScope.ServiceProvider.GetService<DbConnectionFactory>().Obtain()) + using (var conn = await serviceScope.ServiceProvider.GetService<DbConnectionFactory>().Obtain()) system = await conn.QueryFirstOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id }); await _commands.ExecuteAsync(new PKCommandContext(_client, arg, system), argPos, serviceScope.ServiceProvider); } diff --git a/PluralKit.Bot/Services/LogChannelService.cs b/PluralKit.Bot/Services/LogChannelService.cs index 6915c526..8d2ca896 100644 --- a/PluralKit.Bot/Services/LogChannelService.cs +++ b/PluralKit.Bot/Services/LogChannelService.cs @@ -30,7 +30,7 @@ namespace PluralKit.Bot { } public async Task<ITextChannel> GetLogChannel(IGuild guild) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) { var server = await conn.QueryFirstOrDefaultAsync<ServerDefinition>("select * from servers where id = @Id", @@ -46,7 +46,7 @@ namespace PluralKit.Bot { LogChannel = newLogChannel?.Id }; - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) { await conn.QueryAsync( "insert into servers (id, log_channel) values (@Id, @LogChannel) on conflict (id) do update set log_channel = @LogChannel", diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index 17a61c1d..f62dc06e 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -79,7 +79,7 @@ namespace PluralKit.Bot public async Task HandleMessageAsync(IMessage message) { IEnumerable<ProxyDatabaseResult> results; - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) { results = await conn.QueryAsync<PKMember, PKSystem, ProxyDatabaseResult>( "select members.*, systems.* from members, systems, accounts where members.system = systems.id and accounts.system = systems.id and accounts.uid = @Uid", @@ -168,21 +168,29 @@ namespace PluralKit.Bot return HandleMessageDeletionByReaction(message, reaction.UserId); case "\u2753": // Red question mark case "\u2754": // White question mark - return HandleMessageQueryByReaction(message, reaction.UserId); + return HandleMessageQueryByReaction(message, reaction.UserId, reaction.Emote); default: return Task.CompletedTask; } } - private async Task HandleMessageQueryByReaction(Cacheable<IUserMessage, ulong> message, ulong userWhoReacted) + private async Task HandleMessageQueryByReaction(Cacheable<IUserMessage, ulong> message, ulong userWhoReacted, IEmote reactedEmote) { + // Find the user who sent the reaction, so we can DM them var user = await _client.GetUserAsync(userWhoReacted); if (user == null) return; + // Find the message in the DB var msg = await _messageStorage.Get(message.Id); if (msg == null) return; + // DM them the message card await user.SendMessageAsync(embed: await _embeds.CreateMessageInfoEmbed(msg)); + + // And finally remove the original reaction (if we can) + var msgObj = await message.GetOrDownloadAsync(); + if (await msgObj.Channel.HasPermission(ChannelPermission.ManageMessages)) + await msgObj.RemoveReactionAsync(reactedEmote, user); } public async Task HandleMessageDeletionByReaction(Cacheable<IUserMessage, ulong> message, ulong userWhoReacted) diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index c2ef2def..62c1dffd 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -86,6 +86,25 @@ namespace PluralKit.Bot public static string Sanitize(this string input) => Regex.Replace(Regex.Replace(input, "<@[!&]?(\\d{17,19})>", "<\\@$1>"), "@(everyone|here)", "@\u200B$1"); + + public static async Task<ChannelPermissions> PermissionsIn(this IChannel channel) + { + switch (channel) + { + case IDMChannel _: + return ChannelPermissions.DM; + case IGroupChannel _: + return ChannelPermissions.Group; + case IGuildChannel gc: + var currentUser = await gc.Guild.GetCurrentUserAsync(); + return currentUser.GetPermissions(gc); + default: + return ChannelPermissions.None; + } + } + + public static async Task<bool> HasPermission(this IChannel channel, ChannelPermission permission) => + (await PermissionsIn(channel)).Has(permission); } class PKSystemTypeReader : TypeReader diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index 6ed9f466..a3fab10a 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -20,56 +20,56 @@ namespace PluralKit { // TODO: handle HID collision case var hid = Utils.GenerateHid(); - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) 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) { // We have "on conflict do nothing" since linking an account when it's already linked to the same system is idempotent // This is used in import/export, although the pk;link command checks for this case beforehand - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) await conn.ExecuteAsync("insert into accounts (uid, system) values (@Id, @SystemId) on conflict do nothing", new { Id = accountId, SystemId = system.Id }); } public async Task Unlink(PKSystem system, ulong accountId) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) await conn.ExecuteAsync("delete from accounts where uid = @Id and system = @SystemId", new { Id = accountId, SystemId = system.Id }); } public async Task<PKSystem> GetByAccount(ulong accountId) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) return await conn.QuerySingleOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.system = systems.id and accounts.uid = @Id", new { Id = accountId }); } public async Task<PKSystem> GetByHid(string hid) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where systems.hid = @Hid", new { Hid = hid.ToLower() }); } public async Task<PKSystem> GetByToken(string token) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where token = @Token", new { Token = token }); } public async Task<PKSystem> GetById(int id) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where id = @Id", new { Id = id }); } public async Task Save(PKSystem system) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) await conn.ExecuteAsync("update systems set name = @Name, description = @Description, tag = @Tag, avatar_url = @AvatarUrl, token = @Token, ui_tz = @UiTz where id = @Id", system); } public async Task Delete(PKSystem system) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) await conn.ExecuteAsync("delete from systems where id = @Id", system); } public async Task<IEnumerable<ulong>> GetLinkedAccountIds(PKSystem system) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) return await conn.QueryAsync<ulong>("select uid from accounts where system = @Id", new { Id = system.Id }); } } @@ -85,7 +85,7 @@ namespace PluralKit { // TODO: handle collision var hid = Utils.GenerateHid(); - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) return await conn.QuerySingleAsync<PKMember>("insert into members (hid, system, name) values (@Hid, @SystemId, @Name) returning *", new { Hid = hid, SystemID = system.Id, @@ -94,13 +94,13 @@ namespace PluralKit { } public async Task<PKMember> GetByHid(string hid) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) return await conn.QuerySingleOrDefaultAsync<PKMember>("select * from members where hid = @Hid", new { Hid = hid.ToLower() }); } public async Task<PKMember> GetByName(PKSystem system, string name) { // QueryFirst, since members can (in rare cases) share names - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) return await conn.QueryFirstOrDefaultAsync<PKMember>("select * from members where lower(name) = lower(@Name) and system = @SystemID", new { Name = name, SystemID = system.Id }); } @@ -113,23 +113,23 @@ namespace PluralKit { } public async Task<IEnumerable<PKMember>> GetBySystem(PKSystem system) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) return await conn.QueryAsync<PKMember>("select * from members where system = @SystemID", new { SystemID = system.Id }); } public async Task Save(PKMember member) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) await conn.ExecuteAsync("update members set name = @Name, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, prefix = @Prefix, suffix = @Suffix where id = @Id", member); } public async Task Delete(PKMember member) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) await conn.ExecuteAsync("delete from members where id = @Id", member); } public async Task<int> MessageCount(PKMember member) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) return await conn.QuerySingleAsync<int>("select count(*) from messages where member = @Id", member); } } @@ -155,7 +155,7 @@ namespace PluralKit { } public async Task Store(ulong senderId, ulong messageId, ulong channelId, PKMember member) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) await conn.ExecuteAsync("insert into messages(mid, channel, member, sender) values(@MessageId, @ChannelId, @MemberId, @SenderId)", new { MessageId = messageId, ChannelId = channelId, @@ -166,7 +166,7 @@ namespace PluralKit { public async Task<StoredMessage> Get(ulong id) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) return (await conn.QueryAsync<PKMessage, PKMember, PKSystem, StoredMessage>("select messages.*, members.*, systems.* from messages, members, systems where mid = @Id and messages.member = members.id and systems.id = members.system", (msg, member, system) => new StoredMessage { Message = msg, @@ -176,7 +176,7 @@ namespace PluralKit { } public async Task Delete(ulong id) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) await conn.ExecuteAsync("delete from messages where mid = @Id", new { Id = id }); } } @@ -193,7 +193,7 @@ namespace PluralKit { public async Task RegisterSwitch(PKSystem system, IEnumerable<PKMember> members) { // Use a transaction here since we're doing multiple executed commands in one - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) using (var tx = conn.BeginTransaction()) { // First, we insert the switch itself @@ -218,20 +218,20 @@ namespace PluralKit { { // TODO: refactor the PKSwitch data structure to somehow include a hydrated member list // (maybe when we get caching in?) - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) return await conn.QueryAsync<PKSwitch>("select * from switches where system = @System order by timestamp desc limit @Count", new {System = system.Id, Count = count}); } public async Task<IEnumerable<int>> GetSwitchMemberIds(PKSwitch sw) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) return await conn.QueryAsync<int>("select member from switch_members where switch = @Switch", new {Switch = sw.Id}); } public async Task<IEnumerable<PKMember>> GetSwitchMembers(PKSwitch sw) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) return await conn.QueryAsync<PKMember>( "select * from switch_members, members where switch_members.member = members.id and switch_members.switch = @Switch", new {Switch = sw.Id}); @@ -241,14 +241,14 @@ namespace PluralKit { public async Task MoveSwitch(PKSwitch sw, Instant time) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) await conn.ExecuteAsync("update switches set timestamp = @Time where id = @Id", new {Time = time, Id = sw.Id}); } public async Task DeleteSwitch(PKSwitch sw) { - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) await conn.ExecuteAsync("delete from switches where id = @Id", new {Id = sw.Id}); } @@ -273,7 +273,7 @@ namespace PluralKit { // this makes sure the return list has the same instances of PKMember throughout, which is important for the dictionary // key used in GetPerMemberSwitchDuration below Dictionary<int, PKMember> memberObjects; - using (var conn = _conn.Obtain()) + using (var conn = await _conn.Obtain()) { memberObjects = (await conn.QueryAsync<PKMember>( "select distinct members.* from members, switch_members where switch_members.switch = any(@Switches) and switch_members.member = members.id", // lol postgres specific `= any()` syntax diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index f50cf248..b0c89c22 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text.RegularExpressions; +using System.Threading.Tasks; using Dapper; using Microsoft.Extensions.Configuration; using Newtonsoft.Json; @@ -343,9 +344,11 @@ namespace PluralKit _connectionString = connectionString; } - public IDbConnection Obtain() + public async Task<IDbConnection> Obtain() { - return new NpgsqlConnection(_connectionString); + var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + return conn; } } } From d78e4c45023d748beb6ed0535e0642b21384ea5c Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sun, 14 Jul 2019 21:14:16 +0200 Subject: [PATCH 096/103] Send export file in DMs --- .../Commands/ImportExportCommands.cs | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/PluralKit.Bot/Commands/ImportExportCommands.cs b/PluralKit.Bot/Commands/ImportExportCommands.cs index 28123248..8f05a00b 100644 --- a/PluralKit.Bot/Commands/ImportExportCommands.cs +++ b/PluralKit.Bot/Commands/ImportExportCommands.cs @@ -4,7 +4,9 @@ using System.Linq; using System.Net.Http; using System.Text; using System.Threading.Tasks; +using Discord; using Discord.Commands; +using Discord.Net; using Newtonsoft.Json; namespace PluralKit.Bot.Commands @@ -104,14 +106,31 @@ namespace PluralKit.Bot.Commands [MustHaveSystem] public async Task Export() { - await Context.BusyIndicator(async () => + var json = await Context.BusyIndicator(async () => { + // Make the actual data file var data = await DataFiles.ExportSystem(Context.SenderSystem); - var json = JsonConvert.SerializeObject(data, Formatting.None); - - var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - await Context.Channel.SendFileAsync(stream, "system.json", $"{Emojis.Success} Here you go!"); + return JsonConvert.SerializeObject(data, Formatting.None); }); + + + // Send it as a Discord attachment *in DMs* + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + try + { + await Context.User.SendFileAsync(stream, "system.json", $"{Emojis.Success} Here you go!"); + + // If the original message wasn't posted in DMs, send a public reminder + if (!(Context.Channel is IDMChannel)) + await Context.Channel.SendMessageAsync($"{Emojis.Success} Check your DMs!"); + } + catch (HttpException) + { + // If user has DMs closed, tell 'em to open them + await Context.Channel.SendMessageAsync( + $"{Emojis.Error} Could not send the data file in your DMs. Do you have DMs closed?"); + } } } } \ No newline at end of file From 1d35838fa45f1a2690701f87f5bb83372f967d9c Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sun, 14 Jul 2019 21:19:48 +0200 Subject: [PATCH 097/103] Hint at 5-char member ID when registering multi-name member --- PluralKit.Bot/Commands/MemberCommands.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index b9db1d9c..2b183e35 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -48,7 +48,7 @@ namespace PluralKit.Bot.Commands // Send confirmation and space hint await Context.Channel.SendMessageAsync($"{Emojis.Success} Member \"{memberName.Sanitize()}\" (`{member.Hid}`) registered! Type `pk;help member` for a list of commands to edit this member."); - if (memberName.Contains(" ")) await Context.Channel.SendMessageAsync($"{Emojis.Note} Note that this member's name contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it."); + if (memberName.Contains(" ")) await Context.Channel.SendMessageAsync($"{Emojis.Note} Note that this member's name contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it, or just use the member's 5-character ID (which is `{member.Hid}`)."); } [Command("rename")] From c6d6a728c95012a02980ac6f28193e13d43c4278 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sun, 14 Jul 2019 21:27:13 +0200 Subject: [PATCH 098/103] Fix proxying members named 'Clyde' --- PluralKit.Bot/Services/ProxyService.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index f62dc06e..54080cc3 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -4,6 +4,7 @@ using System.Data; using System.Linq; using System.Net; using System.Net.Http; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Dapper; using Discord; @@ -130,7 +131,10 @@ namespace PluralKit.Bot return true; } - private async Task<IMessage> ExecuteWebhook(IWebhook webhook, string text, string username, string avatarUrl, IAttachment attachment) { + private async Task<IMessage> ExecuteWebhook(IWebhook webhook, string text, string username, string avatarUrl, IAttachment attachment) + { + username = FixClyde(username); + // TODO: DiscordWebhookClient's ctor does a call to GetWebhook that may be unnecessary, see if there's a way to do this The Hard Way :tm: // TODO: this will probably crash if there are multiple consecutive failures, perhaps have a loop instead? DiscordWebhookClient client; @@ -219,5 +223,15 @@ namespace PluralKit.Bot { await _messageStorage.Delete(message.Id); } + + private string FixClyde(string name) + { + var match = Regex.Match(name, "clyde", RegexOptions.IgnoreCase); + if (!match.Success) return name; + + // Put a hair space (\u200A) between the "c" and the "lyde" in the match to avoid Discord matching it + // since Discord blocks webhooks containing the word "Clyde"... for some reason. /shrug + return name.Substring(0, match.Index + 1) + '\u200A' + name.Substring(match.Index + 1); + } } } \ No newline at end of file From 7e999f0a1d9d2d320317163da41065a0df9d60b7 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sun, 14 Jul 2019 21:48:10 +0200 Subject: [PATCH 099/103] Ensure switch fronter order is stable --- PluralKit.Core/Stores.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index a3fab10a..29774848 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -225,7 +225,7 @@ namespace PluralKit { public async Task<IEnumerable<int>> GetSwitchMemberIds(PKSwitch sw) { using (var conn = await _conn.Obtain()) - return await conn.QueryAsync<int>("select member from switch_members where switch = @Switch", + return await conn.QueryAsync<int>("select member from switch_members where switch = @Switch order by switch_members.id", new {Switch = sw.Id}); } @@ -233,7 +233,7 @@ namespace PluralKit { { using (var conn = await _conn.Obtain()) return await conn.QueryAsync<PKMember>( - "select * from switch_members, members where switch_members.member = members.id and switch_members.switch = @Switch", + "select * from switch_members, members where switch_members.member = members.id and switch_members.switch = @Switch order by switch_members.id", new {Switch = sw.Id}); } From 76d757cae1fa6d74b344ac17d5e63ea011b9af7e Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sun, 14 Jul 2019 23:18:51 +0200 Subject: [PATCH 100/103] Show member fronter card on separate lines --- PluralKit.Bot/Services/EmbedService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 4e3ef3bd..dcc20d6c 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -81,8 +81,8 @@ namespace PluralKit.Bot { var timeSinceSwitch = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp; return new EmbedBuilder() .WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? Color.Blue) - .AddField("Current fronter", members.Count > 0 ? string.Join(", ", members.Select(m => m.Name)) : "*(no fronter)*", true) - .AddField("Since", $"{Formats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(zone))} ({Formats.DurationFormat.Format(timeSinceSwitch)} ago)", true) + .AddField("Current fronter", members.Count > 0 ? string.Join(", ", members.Select(m => m.Name)) : "*(no fronter)*") + .AddField("Since", $"{Formats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(zone))} ({Formats.DurationFormat.Format(timeSinceSwitch)} ago)") .Build(); } From 382f533dda6cddc72d5f8a91a3022252a6db6ce5 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Sun, 14 Jul 2019 23:49:14 +0200 Subject: [PATCH 101/103] Pluralize 'fronter' when applicable --- PluralKit.Bot/Services/EmbedService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index dcc20d6c..09bb7081 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Linq; using System.Threading.Tasks; using Discord; +using Humanizer; using NodaTime; namespace PluralKit.Bot { @@ -81,7 +82,7 @@ namespace PluralKit.Bot { var timeSinceSwitch = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp; return new EmbedBuilder() .WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? Color.Blue) - .AddField("Current fronter", members.Count > 0 ? string.Join(", ", members.Select(m => m.Name)) : "*(no fronter)*") + .AddField($"Current {"fronter".ToQuantity(members.Count, ShowQuantityAs.None)}", members.Count > 0 ? string.Join(", ", members.Select(m => m.Name)) : "*(no fronter)*") .AddField("Since", $"{Formats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(zone))} ({Formats.DurationFormat.Format(timeSinceSwitch)} ago)") .Build(); } From 0a8aeebb23a7c6741f2e7f8fc7e0ddbe332ccb58 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 15 Jul 2019 00:05:19 +0200 Subject: [PATCH 102/103] Fix error showing system card with deleted linked account --- PluralKit.Bot/Services/EmbedService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 09bb7081..56135252 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -27,7 +27,7 @@ namespace PluralKit.Bot { 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 users = await Task.WhenAll(accounts.Select(async uid => (await _client.GetUserAsync(uid))?.NameAndMention() ?? $"(deleted account {uid})")); var eb = new EmbedBuilder() .WithColor(Color.Blue) From 55aa90b971fe7a3e9a2afc2c4866d80ac73bbd51 Mon Sep 17 00:00:00 2001 From: Ske <voltasalt@gmail.com> Date: Mon, 15 Jul 2019 15:28:32 +0200 Subject: [PATCH 103/103] Fix member info usage string --- PluralKit.Bot/Commands/MemberCommands.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index 2b183e35..637bd093 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -204,7 +204,7 @@ namespace PluralKit.Bot.Commands [Command] [Alias("view", "show", "info")] - [Remarks("member")] + [Remarks("member <member>")] public async Task ViewMember(PKMember member) { var system = await Systems.GetById(member.System);