From 7e9b7dcc983672849c68955f5bd40380328ca3de Mon Sep 17 00:00:00 2001 From: Ske Date: Thu, 13 Jun 2019 16:53:04 +0200 Subject: [PATCH] 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() as NpgsqlConnection; - connection.ConnectionString = services.GetRequiredService().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() ?? new CoreConfig()) .AddTransient(_ => _config.GetSection("PluralKit").GetSection("Bot").Get() ?? new BotConfig()) - .AddScoped(svc => new NpgsqlConnection(svc.GetRequiredService().Database)) + .AddScoped(svc => + { + + var conn = new NpgsqlConnection(svc.GetRequiredService().Database); + conn.Open(); + return conn; + }) .AddSingleton() .AddSingleton() @@ -68,6 +73,7 @@ namespace PluralKit.Bot .AddTransient() .AddTransient() .AddTransient() + .AddTransient() .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 + { + public SystemStore Systems { get; set; } + public SwitchStore Switches { get; set; } + + [Command] + [Remarks("switch [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 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 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 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("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> 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("select * from switches where system = @System order by timestamp desc limit @Count", new {System = system.Id, Count = count}); + } + + public async Task> GetSwitchMembers(PKSwitch sw) + { + return await _connection.QueryAsync( + "select * from switch_members, members where switch_members.member = members.id and switch_members.switch = @Switch", + new {Switch = sw.Id}); + } + + public async Task 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 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