using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using RhinoReminds.Utilities; using S22.Xmpp; using S22.Xmpp.Im; using SBRL.Geometry; namespace RhinoReminds { public class OnConnectedEventArgs : EventArgs { } public delegate void OnConnectedHandler(object sender, OnConnectedEventArgs eventArgs); public class ClientListener { public bool Debug { get; set; } = false; public event OnConnectedHandler OnConnected; public readonly Jid Jid; public string Username => Jid.Node; public string Hostname => Jid.Domain; private readonly string password; public readonly List AllowedDomains = new List(); public string ReminderFilePath { get; set; } = "./reminders.xml"; private ReminderList reminderList = new ReminderList(); private CancellationTokenSource reminderWatcherReset; private CancellationToken reminderWatcherResetToken => reminderWatcherReset.Token; private SimpleXmppClient client; /// /// The initial number of seconds to wait before trying to reconnect to the /// server again if we loose our connection again. /// private readonly int defaultBackoffDelay = 1; private readonly float backoffDelayMultiplier = 2; /// /// If a connection attempt doesn't succeed in this number of seconds, /// give up and try again later. /// private readonly int giveUpTimeout = 30; public ClientListener(string inJid, string inPassword) { Jid = new Jid(inJid); password = inPassword; } public async Task Start() { if (File.Exists(ReminderFilePath)) { Console.WriteLine($"[Rhino/Startup] Loading reminders list from {ReminderFilePath}"); reminderList = ReminderList.FromXmlFile(ReminderFilePath); } // Connect to the server. This starts it's own thread that doesn't block the program exiting, apparently await connect(); client.SetStatus(Availability.Online); await watchForReminders(); } private async Task reconnect() { // 1: Ensure we're disconnected from the server. disconnect(); float nextBackoffDelay = defaultBackoffDelay; do { nextBackoffDelay *= backoffDelayMultiplier; Console.Error.WriteLine($"[Rhino/Reconnect] Reconnecting in {TimeSpan.FromSeconds(nextBackoffDelay).ToString()}."); Thread.Sleep((int)(nextBackoffDelay * 1000)); Console.WriteLine("[Rhino/Reconnect] Attempting to reconnect to the server"); } while (!await connect()); } private async Task connect() { if (client != null) { if (client.Connected) { return true; } else { client.Dispose(); client = null; } } DateTime startTime = DateTime.Now; client = new SimpleXmppClient(Jid, password); client.Error += errorHandler; client.Message += messageHandlerRoot; client.SubscriptionRequest += subscriptionRequestHandler; client.Connect("RhinoReminds"); while (!client.Connected) { if ((DateTime.Now - startTime).TotalSeconds > giveUpTimeout) return false; await Task.Delay(100); } Console.WriteLine($"[Rhino/Setup] Connected as {Jid}"); OnConnected?.Invoke(this, new OnConnectedEventArgs()); return true; } private void disconnect() { client.Close(); client.Dispose(); client = null; Console.WriteLine($"[Rhino] Disconnected from server."); } #region XMPP Event Handling private bool subscriptionRequestHandler(Jid from) { if (!AllowedDomains.Contains("*") && !AllowedDomains.Contains(from.Domain)) { client.SendChatMessage(from, "Sorry! The domain of your JID doesn't match the ones in my allowed list."); return false; } Console.WriteLine($"[Rhino/SubscriptionRequest] Approving subscription from {from}"); return true; } private void errorHandler(object sender, S22.Xmpp.Im.ErrorEventArgs e) { Console.Error.WriteLine($"[Error] {e.Reason}: {e.Exception}"); if(!client.Connected || e.Exception is IOException) { reconnect().Wait(); } } private void messageHandlerRoot(object sender, MessageEventArgs eventArgs) { Console.WriteLine($"[Rhino/Reciever] [Message] {eventArgs.Jid} | {eventArgs.Message.Body}"); try { messageHandler(eventArgs.Message); } catch (Exception error) { Console.Error.WriteLine(error); client.SendChatReply(eventArgs.Message, "Oops! I encountered an error. Please report this to my operator!"); client.SendChatReply(eventArgs.Message, $"Technical details: {WebUtility.HtmlEncode(error.ToString())} (stack trace available in server log) "); } } #endregion public void SetAvatar(string avatarFilepath) { if (!client.Connected) throw new InvalidOperationException("Error: The XMPP client isn't connected, so the avatar can't be updated."); client.SetAvatar(avatarFilepath); } private void messageHandler(Message message) { if (!AllowedDomains.Contains("*") && !AllowedDomains.Contains(message.From.Domain)) { client.SendChatMessage(message.From, "Sorry! The domain of your JID doesn't match the ones in my allowed list."); return; } string messageText = message.Body; string[] parts = Regex.Split(messageText.Trim(), @"\s+"); string instruction = parts[0].ToLower(); switch (parts[0].ToLower()) { case "help": client.SendChatReply(message, "Hello! I'm a reminder bot written by Starbeamrainbowlabs."); client.SendChatReply(message, "I can understand messages you send me in regular english."); client.SendChatReply(message, "I figure out what you want me to do by looking at the " + "first word you say, and how you want me to do it by using my AI."); client.SendChatReply(message, "I currently understand the following instructions:\n"); client.SendChatReply(message, "**Remind:** Set a reminder"); client.SendChatReply(message, "**List / Show:** List the reminders I have set"); client.SendChatReply(message, "**Delete / Remove:** Delete a reminder by it's number (find this in the reminder list from the instruction above)"); client.SendChatReply(message, "**Version:** Show the program version I am currently running"); client.SendChatReply(message, "\nExample: 'Remind me to feed the cat tomorrow at 6pm'"); break; case "version": client.SendChatReply(message, $"I'm currently running {Program.Version}."); break; case "delete": case "remove": List failed = new List(), succeeded = new List(); foreach (int nextId in AIRecogniser.RecogniseNumbers(message.Body)) { Reminder nextReminder = reminderList.GetById(nextId); if (nextReminder.JidObj != message.From.GetBareJid()) { failed.Add(nextId); continue; } reminderList.DeleteReminder(nextReminder); succeeded.Add(nextId); } if (failed.Count > 0) { string response = string.Join(", ", failed.Select((int nextId) => $"#{nextId}")); response = $"Sorry! I can't delete reminder{(failed.Count != 1 ? "s" : "")} {response}, as you didn't create {(failed.Count != 1 ? "them":"it")}."; client.SendChatReply(message, response); } if (succeeded.Count > 0) { // Ensure that the reminder thread picks up the changes interruptReminderWatcher(); string response = string.Join(", ", succeeded.Select((int nextId) => $"#{nextId}")); response = $"Deleted reminder{(succeeded.Count != 1 ? "s" : "")} {response} successfully."; client.SendChatReply(message, response); } break; case "list": case "show": // Filter by reminders for this user. IEnumerable userReminderList = reminderList.Reminders.Where( (Reminder next) => message.From.GetBareJid() == next.JidObj.GetBareJid() ); StringBuilder listMessage = new StringBuilder("I've got the following reminders on my list:\n"); foreach (Reminder nextReminder in userReminderList) { listMessage.AppendLine($"#{nextReminder.Id}: {nextReminder.Message} at {nextReminder.Time}"); } listMessage.AppendLine(); listMessage.AppendLine($"({userReminderList.Count()} total)"); client.SendChatReply(message, listMessage.ToString()); break; case "remind": DateTime dateTime; string rawDateTimeString; try { dateTime = AIRecogniser.RecogniseDateTime(messageText, out rawDateTimeString); } catch (AIException error) { client.SendChatReply(message, "Sorry, I had trouble figuring out when you wanted reminding about that!"); client.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 = messageText.Remove(dateStringLocation.Min, dateStringLocation.Stride) .ReplaceMultiple(new string[] { @"\s{2,}", @"^remind\s+(?:me\s+)?", @"^me\s+", @"^on\s+", @"\byou\b", @"\byour\b", @"\bmy\b", @"&" // Ampersands cause a crash when sending! }, new string[] { " ", "", "", "", @"me", @"my", "your", @"and" }, RegexOptions.IgnoreCase).Trim(); Reminder newReminder = reminderList.CreateReminder(message.From, dateTime, reminder); if (newReminder == null) { client.SendChatReply( message, "Oops! It looks like you've already got a reminder idential " + "to that one set, so I wasn't able to set a reminder for you. Please contact my " + "operator, as this is probably a bug." ); break; } client.SendChatReply(message, $"Ok! I'll remind you {reminder} at {dateTime}."); reminderList.Save(ReminderFilePath); break; default: client.SendChatReply(message, "I don't understand that. Try rephrasing it or asking for help."); break; } } #region Outgoing #endregion #region Reminder Listening private void interruptReminderWatcher() { CancellationTokenSource oldToken = reminderWatcherReset; reminderWatcherReset = new CancellationTokenSource(); oldToken?.Cancel(); } private async Task watchForReminders() { interruptReminderWatcher(); Reminder nextReminder = reminderList.GetNextReminder(); // ----- Events ----- // This will run on the firing thread, not on this thread reminderList.OnReminderListUpdate += (object sender, ReminderListUpdateEventArgs eventArgs) => { Reminder newNextReminder = reminderList.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; interruptReminderWatcher(); } }; // ------------------ while (true) { nextReminder = reminderList.GetNextReminder(); // Wait for the next reminder TimeSpan nextWaitingTime; try { if (nextReminder != null) { nextWaitingTime = nextReminder.Time - DateTime.Now; if (DateTime.Now < nextReminder.Time) { Console.WriteLine($"[Rhino/Reminderd] Next reminder: {nextReminder}"); Console.WriteLine($"[Rhino/Reminderd] Sleeping for {nextWaitingTime}"); int nextWaitingTimeMs = (int)nextWaitingTime.TotalMilliseconds; if (nextWaitingTimeMs < 0) // if it overflows, sort it out nextWaitingTimeMs = int.MaxValue; await Task.Delay(nextWaitingTimeMs, reminderWatcherResetToken); } } else { Console.WriteLine("[Rhino/Reminderd] Sleeping until interrupted"); await Task.Delay(Timeout.Infinite, reminderWatcherResetToken); } } catch (TaskCanceledException) { Console.WriteLine("[Rhino/Reminderd] Sleep interrupted, recalculating"); continue; } if (reminderWatcherResetToken.IsCancellationRequested) { Console.WriteLine("[Rhino/Reminderd] Sleep interrupted, recalculating (but no exception thrown)"); continue; } if (nextReminder.Time > DateTime.Now.AddSeconds(-1)) { Console.WriteLine("[Rhino/Reminderd] Didn't sleep for long enough, going back to bed *yawn*"); continue; } Console.WriteLine($"[Rhino/Reminderd] Sending notification {nextReminder}"); Jid targetJid = nextReminder.Jid; // Apparently nextReminder is null after the sendAndDeleteReminder() call - very odd! sendAndDeleteReminder(nextReminder); if (nextWaitingTime.TotalMilliseconds < 0) { client.SendChatMessage( targetJid, "(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)" ); } } } private void sendAndDeleteReminder(Reminder nextReminder) { try { client.SendChatMessage( nextReminder.Jid, $"Hello! You asked me to remind you {nextReminder.Message} at {nextReminder.Time}.".Trim().Replace(@"\s+", " ") ); } catch (Exception error) { Console.Error.WriteLine($"[Rhino/Reminderd] Caught error sending message to client: {error}"); Console.Error.WriteLine($"[Rhink/Reminderd] Offending reminder: {nextReminder}"); client.SendChatMessage(nextReminder.Jid, "Oops! I encountered an error sending you a reminder. Please contact my operator!"); } reminderList.DeleteReminder(nextReminder); reminderList.Save(ReminderFilePath); } #endregion } }