Add switch commands for adding and moving
This commit is contained in:
		| @@ -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}"); | ||||
|                 } | ||||
|             } | ||||
|   | ||||
							
								
								
									
										92
									
								
								PluralKit.Bot/Commands/SwitchCommands.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								PluralKit.Bot/Commands/SwitchCommands.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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."); | ||||
|     } | ||||
| } | ||||
| @@ -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."); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -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; } | ||||
|     } | ||||
| } | ||||
| @@ -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}); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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'"; | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user