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, IUser user = null, TimeSpan? timeout = null) { await message.AddReactionsAsync(new[] {new Emoji(Emojis.Success), new Emoji(Emojis.Error)}); 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; } 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 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(this ICommandContext ctx, ICollection items, int itemsPerPage, string title, Action> renderer) { // TODO: make this generic enough we can use it in Choose below 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 Choose(this ICommandContext ctx, string description, IList items, Func 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 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); public static async Task BusyIndicator(this ICommandContext ctx, Func f, string emoji = "\u23f3" /* hourglass */) { await ctx.BusyIndicator(async () => { await f(); return null; }, emoji); } public static async Task BusyIndicator(this ICommandContext ctx, Func> 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); } } } }