From 0b73e2f1c764c06ba2c9191895352ebdec90df3e Mon Sep 17 00:00:00 2001
From: Starbeamrainbowlabs <sbrl@starbeamrainbowlabs.com>
Date: Sat, 10 Nov 2018 18:15:30 +0000
Subject: [PATCH] Do tons of work. It's functional.... almost :P Oops - I
 should have comitted earlier :P

---
 RhinoReminds/AIRecogniser.cs     |  69 +++++++++++
 RhinoReminds/ClientListener.cs   | 202 +++++++++++++++++++++++++------
 RhinoReminds/Exceptions.cs       |  16 +++
 RhinoReminds/Program.cs          |   8 +-
 RhinoReminds/Reminder.cs         |  14 ++-
 RhinoReminds/ReminderList.cs     |  31 ++++-
 RhinoReminds/RhinoReminds.csproj |  18 ++-
 RhinoReminds/Utilities/Range.cs  |  47 +++++++
 RhinoReminds/packages.config     |   4 +-
 9 files changed, 360 insertions(+), 49 deletions(-)
 create mode 100644 RhinoReminds/AIRecogniser.cs
 create mode 100644 RhinoReminds/Exceptions.cs
 create mode 100644 RhinoReminds/Utilities/Range.cs

diff --git a/RhinoReminds/AIRecogniser.cs b/RhinoReminds/AIRecogniser.cs
new file mode 100644
index 0000000..56ea474
--- /dev/null
+++ b/RhinoReminds/AIRecogniser.cs
@@ -0,0 +1,69 @@
+﻿using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Recognizers.Text;
+using Microsoft.Recognizers.Text.DateTime;
+using SBRL.Geometry;
+
+namespace RhinoReminds
+{
+
+	public static class AIRecogniser
+	{
+		public static (DateTime, TimeSpan) RecogniseDateTimeRange(string source, out string rawString)
+		{
+			List<ModelResult> aiResults = DateTimeRecognizer.RecognizeDateTime(source, Culture.English);
+			if (aiResults.Count == 0)
+				throw new AIException("Error: Couldn't recognise any time ranges in that source string.");
+
+			/* Example contents of the below dictionary:
+				[0]: {[timex, 2018-11-11T06:15]}
+				[1]: {[type, datetime]}
+				[2]: {[value, 2018-11-11 06:15:00]}
+			*/
+
+			rawString = aiResults[0].Text;
+			Dictionary<string, string> aiResult = unwindResult(aiResults[0]);
+			foreach (KeyValuePair<string, string> kvp in aiResult)
+				Console.WriteLine($"{kvp.Key}: {kvp.Value}");
+			string type = aiResult["type"];
+
+			if (type != "datetimerange")
+				throw new AIException($"Error: An invalid type of {type} was encountered ('datetimerange' expected).");
+
+
+			return (
+				DateTime.Parse(aiResult["start"]),
+				DateTime.Parse(aiResult["end"]) - DateTime.Parse(aiResult["start"])
+			);
+		}
+
+		public static DateTime RecogniseDateTime(string source, out string rawString)
+		{
+			List<ModelResult> aiResults = DateTimeRecognizer.RecognizeDateTime(source, Culture.English);
+			if (aiResults.Count == 0)
+				throw new AIException("Error: Couldn't recognise any dates or times in that source string.");
+
+			/* Example contents of the below dictionary:
+				[0]: {[timex, 2018-11-11T06:15]}
+				[1]: {[type, datetime]}
+				[2]: {[value, 2018-11-11 06:15:00]}
+			*/
+
+			rawString = aiResults[0].Text;
+			Dictionary<string, string> aiResult = unwindResult(aiResults[0]);
+			string type = aiResult["type"];
+			if (!(new string[] { "datetime", "date", "time", "datetimerange", "daterange", "timerange" }).Contains(type))
+				throw new AIException($"Error: An invalid type of {type} was encountered ('datetime' expected).");
+
+
+			string result = type == "datetimerange" ? aiResult["start"] : aiResult["value"];
+			return DateTime.Parse(result);
+		}
+
+
+		private static Dictionary<string, string> unwindResult(ModelResult modelResult) {
+			return (modelResult.Resolution["values"] as List<Dictionary<string, string>>)[0];
+		}
+	}
+}
diff --git a/RhinoReminds/ClientListener.cs b/RhinoReminds/ClientListener.cs
index 8094743..2a0f2a8 100644
--- a/RhinoReminds/ClientListener.cs
+++ b/RhinoReminds/ClientListener.cs
@@ -1,39 +1,49 @@
 ﻿using System;
-using System.Collections.Generic;
+using System.IO;
 using System.Linq;
+using System.Net;
 using System.Text.RegularExpressions;
 using System.Threading;
 using System.Threading.Tasks;
-using Microsoft.Recognizers.Text;
-using Microsoft.Recognizers.Text.DateTime;
 using Newtonsoft.Json;
 using S22.Xmpp;
 using S22.Xmpp.Client;
 using S22.Xmpp.Im;
+using SBRL.Geometry;
 
 namespace RhinoReminds
 {
 	public class ClientListener
 	{
+		public bool Debug { get; set; } = false;
+
 		public readonly string Jid;
 		public string Username => Jid.Split('@')[0];
 		public string Hostname => Jid.Split('@')[1];
 		private readonly string password;
 
+		public string ReminderFilePath { get; set; } = "./Reminders.json";
 		private ReminderList reminders = new ReminderList();
 
 		private XmppClient client;
 
-		public ClientListener(string inJid, string inPassword) {
+		public ClientListener(string inJid, string inPassword)
+		{
 			Jid = inJid;
 			password = inPassword;
 		}
 
 		public async Task Start()
 		{
+			if (File.Exists(ReminderFilePath))
+			{
+				Console.WriteLine($"[Rhino/Startup] Loading reminders list from {ReminderFilePath}");
+				reminders = JsonConvert.DeserializeObject<ReminderList>(File.ReadAllText(ReminderFilePath));
+			}
+
 			client = new XmppClient(Hostname, Username, password);
 			client.Error += errorHandler;
-			client.Message += messageHandler;
+			client.Message += messageHandlerRoot;
 			client.SubscriptionRequest += subscriptionRequestHandler;
 
 			// Connect to the server. This starts it's own thread that doesn't block the program exiting, apparently
@@ -46,63 +56,181 @@ namespace RhinoReminds
 
 			//client.SetStatus(Availability.Online);
 
-			await Task.Delay(Timeout.Infinite);
+			await watchForReminders();
 		}
 
-		private bool subscriptionRequestHandler(Jid from) {
+		#region XMPP Event Handling
+		private bool subscriptionRequestHandler(Jid from)
+		{
 			Console.WriteLine($"[Rhino/SubscriptionRequest] Approving subscription from {from}");
 			return true;
 		}
 
-		private void errorHandler(object sender, ErrorEventArgs e) {
+		private void errorHandler(object sender, S22.Xmpp.Im.ErrorEventArgs e)
+		{
 			Console.Error.WriteLine($"Error {e.Reason}: {e.Exception}");
 		}
 
-		private void messageHandler(object sender, MessageEventArgs eventArgs) {
+		private void messageHandlerRoot(object sender, MessageEventArgs eventArgs)
+		{
 			Console.WriteLine($"[Rhino/Reciever] [Message] {eventArgs.Jid} | {eventArgs.Message.Body}");
 
-			string message = eventArgs.Message.Body;
-			string[] parts = Regex.Split(eventArgs.Message.Body.Trim(), @"\s+");
-			string instruction = parts[0].ToLower();
+			try
+			{
+				messageHandler(eventArgs.Message);
+			}
+			catch (Exception error)
+			{
+				Console.Error.WriteLine(error);
+				sendChatReply(eventArgs.Message, "Oops! I encountered an error. Please report this to my operator!");
+				sendChatReply(eventArgs.Message, $"Technical details: {WebUtility.HtmlEncode(error.ToString())} (stack trace available in server log) ");
 
-			if (parts.Select((string nextPart) => nextPart.ToLower()).Contains("remind")) {
-				Console.WriteLine("[Rhino/Reciever] Identified remind request");
-
-				List<ModelResult> dateAiResult = DateTimeRecognizer.RecognizeDateTime(message, Culture.English);
-				if (dateAiResult.Count == 0) {
-					sendChatReply(eventArgs.Message, "Sorry, but I didn't recognise any date or time in that message.");
-					return;
-				}
-
-
-
-				//DateTime dateTime = dateAiResult[0].Resolution["values"]
-
-				sendChatReply(
-					eventArgs.Message,
-					"#1: " + (dateAiResult[0].Resolution["values"] as List<object>)[0].ToString() + "\n" + 
-					"JSON: " + JsonConvert.SerializeObject(
-						dateAiResult[0].Resolution["values"]
-					)
-				);
 			}
 		}
 
+		private void messageHandler(Message message)
+		{
+			string messageText = message.Body;
+			string[] parts = Regex.Split(messageText.Trim(), @"\s+");
+			string instruction = parts[0].ToLower();
+
+
+			switch (parts[0].ToLower())
+			{
+				case "list":
+				case "show":
+					if (parts.Select((n) => n.ToLower()).Contains("all")) {
+
+					}
+
+					// TODO: Identify number
+
+					break;
+
+				case "remind":
+					Console.WriteLine("[Rhino/Reciever] Identified remind request");
+
+					DateTime dateTime; string rawDateTimeString;
+					try {
+						dateTime = AIRecogniser.RecogniseDateTime(messageText, out rawDateTimeString);
+					} catch (AIException error) {
+						sendChatReply(message, "Sorry, I had trouble figuring out when you wanted reminding about that!");
+						sendChatReply(message, $"(Technical details: {error.Message})");
+						return;
+					}
+					Range dateStringLocation = new Range(
+						messageText.IndexOf(rawDateTimeString, StringComparison.OrdinalIgnoreCase),
+						messageText.IndexOf(rawDateTimeString, StringComparison.OrdinalIgnoreCase) + rawDateTimeString.Length
+					);
+					string reminder = Regex.Replace(
+						messageText.Remove(dateStringLocation.Min, dateStringLocation.Stride),
+						@"^remind\s+(?:me\s+)?", "",
+						RegexOptions.IgnoreCase
+					).Replace(@"\s{2,}", " ").Trim();
+
+					if (Debug)
+					{
+						sendChatReply(message, $"[debug] Raw date identified: [{rawDateTimeString}]");
+						sendChatReply(message, $"[debug] Time identified at {dateStringLocation}");
+						sendChatReply(message, $"[debug] Transforming message - phase #1 [{reminder}]");
+						sendChatReply(message, $"[debug] Transforming message - phase #2 [{reminder}]");
+					}
+
+					sendChatReply(message, $"Ok! I'll remind you {reminder} at {dateTime}.");
+
+					Reminder newReminder = reminders.CreateReminder(message.From, dateTime, reminder);
+					reminders.Save(ReminderFilePath);
+					break;
+				default:
+					sendChatReply(message, "I don't understand that. Try rephrasing it or asking for help.");
+					break;
+			}
+		}
+
+		private void sendChatMessage(Jid to, string message) {
+			Console.WriteLine($"[Rhino/Send/Chat] Sending {message} -> {to}");
+			client.SendMessage(
+				to, message,
+				null, null, MessageType.Chat
+			);
+		}
 		private void sendChatReply(Message originalMessage, string reply)
 		{
+			Console.WriteLine($"[Rhino/Send/Reply] Sending {reply} -> {originalMessage.From}");
 			client.SendMessage(
 				originalMessage.From, reply,
 				null, originalMessage.Thread, MessageType.Chat
 			);
 		}
 
-		private bool isContact(Jid jid)
+		#endregion
+
+		#region Reminder Listening
+
+		private async Task watchForReminders()
 		{
-			foreach (RosterItem nextContact in client.GetRoster()) {
-				if (nextContact.Jid == jid)
-					return true;
+			CancellationTokenSource cancellationSource = new CancellationTokenSource();
+			CancellationToken cancellationToken = cancellationSource.Token;
+			Reminder nextReminder = reminders.GetNextReminder();
+			reminders.OnReminderListUpdate += (object sender, Reminder newReminder) => {
+				Reminder newNextReminder = reminders.GetNextReminder();
+				Console.WriteLine("[Rhino/Reminderd/Canceller] Reminder added - comparing.");
+				Console.WriteLine($"[Rhino/Reminderd/Canceller] {nextReminder} / {newNextReminder}");
+				if (nextReminder != newNextReminder) {
+					Console.WriteLine($"[Rhino/Reminderd/Canceller] Cancelling");
+					nextReminder = newNextReminder;
+					cancellationSource.Cancel();
+				}
+			};
+
+			while (true)
+			{
+				TimeSpan nextWaitingTime;
+				try {
+					if (nextReminder != null) {
+						nextWaitingTime = DateTime.Now - nextReminder.Time;
+						if (DateTime.Now < nextReminder.Time) {
+							Console.WriteLine($"[Rhino/Reminderd] Sleeping for {nextWaitingTime}");
+							await Task.Delay(nextWaitingTime, cancellationToken);
+						}
+					} else {
+						Console.WriteLine("[Rhino/Reminderd] Sleeping until interrupted");
+						await Task.Delay(Timeout.Infinite, cancellationToken);
+					}
+				} catch (TaskCanceledException) {
+					Console.WriteLine("[Rhino/Reminderd] Sleep interrupted, recalculating");
+					cancellationSource = new CancellationTokenSource();
+					cancellationToken = cancellationSource.Token;
+					continue;
+				}
+
+				if (cancellationToken.IsCancellationRequested) {
+					Console.WriteLine("[Rhino/Reminderd] Sleep interrupted, recalculating (but no exception thrown)");
+					cancellationSource = new CancellationTokenSource();
+					cancellationToken = cancellationSource.Token;
+
+					continue;
+				}
+
+				Console.WriteLine($"[Rhino/Reminderd] Sending notification {nextReminder}");
+
+				sendChatMessage(
+					nextReminder.Jid,
+					$"Hello! You asked me to remind you {nextReminder.Message} at {nextReminder.Time}.".Replace(@"\s{2,}", " ").Trim()
+				);
+				if (nextWaitingTime.TotalMilliseconds < 0) {
+					sendChatMessage(
+						nextReminder.Jid,
+						"(Sorry I'm late reminding you! I might not have been running at the time, " +
+						"or you might have scheduled a reminder for the past)"
+					);
+				}
+				reminders.DeleteReminder(nextReminder);
+				reminders.Save(ReminderFilePath);
+				nextReminder = reminders.GetNextReminder();
 			}
-			return false;
 		}
+
+		#endregion
 	}
 }
diff --git a/RhinoReminds/Exceptions.cs b/RhinoReminds/Exceptions.cs
new file mode 100644
index 0000000..e333e75
--- /dev/null
+++ b/RhinoReminds/Exceptions.cs
@@ -0,0 +1,16 @@
+﻿using System;
+namespace RhinoReminds
+{
+	[Serializable()]
+	public class AIException : System.Exception
+	{
+		public AIException() : base() { }
+		public AIException(string message) : base(message) { }
+		public AIException(string message, System.Exception inner) : base(message, inner) { }
+
+		// A constructor is needed for serialization when an
+		// exception propagates from a remoting server to the client. 
+		protected AIException(System.Runtime.Serialization.SerializationInfo info,
+			System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
+	}
+}
diff --git a/RhinoReminds/Program.cs b/RhinoReminds/Program.cs
index 9cf8156..f6744e9 100644
--- a/RhinoReminds/Program.cs
+++ b/RhinoReminds/Program.cs
@@ -1,5 +1,7 @@
 ﻿using System;
 using System.Collections.Generic;
+using System.IO;
+using S22.Xmpp;
 using S22.Xmpp.Client;
 using S22.Xmpp.Im;
 
@@ -7,6 +9,7 @@ namespace RhinoReminds
 {
 	public class ProgramSettings
 	{
+		public string Filepath = "./reminders.json";
 		public string Jid = null;
 
 		public string Password = null;
@@ -37,6 +40,7 @@ namespace RhinoReminds
 						Console.WriteLine();
 						Console.WriteLine("Options:");
 						Console.WriteLine("    -h  --help    Show this message");
+						Console.WriteLine($"    -f  --file    Specify where to save reminders (default: {settings.Filepath})");
 						Console.WriteLine();
 						Console.WriteLine("Environment Variables:");
 						Console.WriteLine("    XMPP_JID       The JID to login to");
@@ -59,7 +63,9 @@ namespace RhinoReminds
 
 		public static void Run()
 		{
-			ClientListener client = new ClientListener(settings.Jid, settings.Password);
+			ClientListener client = new ClientListener(settings.Jid, settings.Password) {
+				ReminderFilePath = settings.Filepath
+			};
 			client.Start().Wait();
 		}
 	}
diff --git a/RhinoReminds/Reminder.cs b/RhinoReminds/Reminder.cs
index 93504f1..542c484 100644
--- a/RhinoReminds/Reminder.cs
+++ b/RhinoReminds/Reminder.cs
@@ -1,18 +1,30 @@
 ﻿using System;
+using Newtonsoft.Json;
+using S22.Xmpp;
+
 namespace RhinoReminds
 {
 
 	public class Reminder
 	{
 		public int Id { get; }
+		public string Jid { get; }
+		[JsonIgnore]
+		public Jid JidObj => new Jid(Jid);
 		public DateTime Time { get; }
 		public string Message { get; }
 
-		public Reminder(int inId, DateTime inTime, string inMessage)
+		public Reminder(int inId, string inJid, DateTime inTime, string inMessage)
 		{
 			Id = inId;
+			Jid = inJid;
 			Time = inTime;
 			Message = inMessage;
 		}
+
+		public override string ToString()
+		{
+			return $"[Reminder Id={Id}, Jid={Jid}, Time={Time}, Message={Message}";
+		}
 	}
 }
diff --git a/RhinoReminds/ReminderList.cs b/RhinoReminds/ReminderList.cs
index 17f534f..32d7a87 100644
--- a/RhinoReminds/ReminderList.cs
+++ b/RhinoReminds/ReminderList.cs
@@ -1,5 +1,8 @@
 ﻿using System;
 using System.Collections.Generic;
+using System.IO;
+using Newtonsoft.Json;
+using S22.Xmpp;
 
 namespace RhinoReminds
 {
@@ -8,19 +11,43 @@ namespace RhinoReminds
 	public class ReminderList
 	{
 		private int nextId = 0;
+		private object saveLock = new object();
 		public SortedList<DateTime, Reminder> Reminders = new SortedList<DateTime, Reminder>();
 
 		public event OnReminderListUpdateHandler OnReminderListUpdate;
 
+
 		public ReminderList() {
 
 		}
 
-		public Reminder CreateReminder(DateTime time, string message) {
-			Reminder result = new Reminder(nextId++, time, message);
+		public Reminder CreateReminder(Jid inJid, DateTime time, string message) {
+			Reminder result = new Reminder(nextId++, $"{inJid.Node}@{inJid.Domain}", time, message);
+			Console.WriteLine($"[Rhino/ReminderList] Created reminder {result}");
 			Reminders.Add(time, result);
 			OnReminderListUpdate(this, result);
 			return result;
 		}
+
+		public void Save(string filename)
+		{
+			// Make sure that the reminder thread doesn't try to save the reminders at the exact same time
+			// we receive a request for a new reminder
+			lock (saveLock) { // FUTURE: We could go lockless here with some work, but it's not worth it for the teeny chance & low overhead 
+				File.WriteAllText(filename, JsonConvert.SerializeObject(this));
+			}
+		}
+
+		public Reminder GetNextReminder()
+		{
+			if (Reminders.Count == 0)
+				return null;
+
+			return Reminders.Values[0];
+		}
+
+		public void DeleteReminder(Reminder nextReminder) {
+			Reminders.Remove(nextReminder.Time);
+		}
 	}
 }
diff --git a/RhinoReminds/RhinoReminds.csproj b/RhinoReminds/RhinoReminds.csproj
index 716b444..66c9ea1 100644
--- a/RhinoReminds/RhinoReminds.csproj
+++ b/RhinoReminds/RhinoReminds.csproj
@@ -36,12 +36,6 @@
     <Reference Include="Newtonsoft.Json">
       <HintPath>..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll</HintPath>
     </Reference>
-    <Reference Include="System.Collections.Immutable">
-      <HintPath>..\packages\System.Collections.Immutable.1.4.0\lib\netstandard2.0\System.Collections.Immutable.dll</HintPath>
-    </Reference>
-    <Reference Include="System.ValueTuple">
-      <HintPath>..\packages\System.ValueTuple.4.4.0\lib\net47\System.ValueTuple.dll</HintPath>
-    </Reference>
     <Reference Include="mscorlib" />
     <Reference Include="Microsoft.Recognizers.Definitions">
       <HintPath>..\packages\Microsoft.Recognizers.Text.1.1.2\lib\net462\Microsoft.Recognizers.Definitions.dll</HintPath>
@@ -58,6 +52,12 @@
     <Reference Include="Microsoft.Recognizers.Text.DateTime">
       <HintPath>..\packages\Microsoft.Recognizers.Text.DateTime.1.1.2\lib\net462\Microsoft.Recognizers.Text.DateTime.dll</HintPath>
     </Reference>
+    <Reference Include="System.Collections.Immutable">
+      <HintPath>..\packages\System.Collections.Immutable.1.5.0\lib\netstandard2.0\System.Collections.Immutable.dll</HintPath>
+    </Reference>
+    <Reference Include="System.ValueTuple">
+      <HintPath>..\packages\System.ValueTuple.4.5.0\lib\net47\System.ValueTuple.dll</HintPath>
+    </Reference>
   </ItemGroup>
   <ItemGroup>
     <Compile Include="Program.cs" />
@@ -65,9 +65,15 @@
     <Compile Include="ClientListener.cs" />
     <Compile Include="Reminder.cs" />
     <Compile Include="ReminderList.cs" />
+    <Compile Include="AIRecogniser.cs" />
+    <Compile Include="Exceptions.cs" />
+    <Compile Include="Utilities\Range.cs" />
   </ItemGroup>
   <ItemGroup>
     <None Include="packages.config" />
   </ItemGroup>
+  <ItemGroup>
+    <Folder Include="Utilities\" />
+  </ItemGroup>
   <Import Project="$(MSBuildBinPath)\Microsoft.CSharp.targets" />
 </Project>
\ No newline at end of file
diff --git a/RhinoReminds/Utilities/Range.cs b/RhinoReminds/Utilities/Range.cs
new file mode 100644
index 0000000..05b60e5
--- /dev/null
+++ b/RhinoReminds/Utilities/Range.cs
@@ -0,0 +1,47 @@
+﻿using System;
+
+namespace SBRL.Geometry
+{
+	/// <summary>
+	/// Represents a single 1-dimensional range.
+	/// </summary>
+	public class Range
+	{
+		public int Min { get; set; }
+		public int Max { get; set; }
+
+		/// <summary>
+		/// The difference between the minimum and the maximum number in this range.
+		/// </summary>
+		public int Stride {
+			get {
+				return Max - Min;
+			}
+		}
+
+		public Range(int min, int max)
+		{
+			Min = min;
+			Max = max;
+
+			if (Max < Min)
+				throw new ArgumentException("Error: The maximum provided is greater than the minimum!");
+		}
+
+		public void Shift(int amount)
+		{
+			Min += amount;
+			Max += amount;
+		}
+		public void Multiply(int multiplier)
+		{
+			Min *= multiplier;
+			Max *= multiplier;
+		}
+
+		public override string ToString()
+		{
+			return $"[Range Min={Min}, Max={Max}]";
+		}
+	}
+}
diff --git a/RhinoReminds/packages.config b/RhinoReminds/packages.config
index ec7a2ae..04b32c1 100644
--- a/RhinoReminds/packages.config
+++ b/RhinoReminds/packages.config
@@ -6,6 +6,6 @@
   <package id="Microsoft.Recognizers.Text.NumberWithUnit" version="1.1.2" targetFramework="net47" />
   <package id="Newtonsoft.Json" version="11.0.2" targetFramework="net47" />
   <package id="S22.Xmpp" version="1.0.0.0" targetFramework="net47" />
-  <package id="System.Collections.Immutable" version="1.4.0" targetFramework="net47" />
-  <package id="System.ValueTuple" version="4.4.0" targetFramework="net47" />
+  <package id="System.Collections.Immutable" version="1.5.0" targetFramework="net47" />
+  <package id="System.ValueTuple" version="4.5.0" targetFramework="net47" />
 </packages>
\ No newline at end of file
