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 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 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(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(); @@ -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 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) { 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: "); 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: "); } } \ 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 {