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("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("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 { + public MemberStore Members { get; set; } + + [Command] + public async Task MemberShortList() { + var system = Context.GetContextEntity() ?? 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( + 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() ?? 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( + 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 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 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 AwaitReaction(this ICommandContext ctx, IUserMessage message, IUser user = null, Func predicate = null, TimeSpan? timeout = null) { + var tcs = new TaskCompletionSource(); + Task Inner(Cacheable _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 AwaitMessage(this ICommandContext ctx, IMessageChannel channel, IUser user = null, Func predicate = null, TimeSpan? timeout = null) { + var tcs = new TaskCompletionSource(); + 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(this ICommandContext ctx, ICollection items, int itemsPerPage, string title, Action> 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 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 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 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 AwaitReaction(this ICommandContext ctx, IUserMessage message, IUser user = null, Func predicate = null, TimeSpan? timeout = null) { - var tcs = new TaskCompletionSource(); - Task Inner(Cacheable _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 AwaitMessage(this ICommandContext ctx, IMessageChannel channel, IUser user = null, Func predicate = null, TimeSpan? timeout = null) { - var tcs = new TaskCompletionSource(); - 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 handler) { + try { + await task; + } catch (Exception e) { + handler(e); + } + } + + public static async Task TimeoutAfter(this Task 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 TimeoutAfter(this Task 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 {