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.Client; 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 string Jid; public string Username => Jid.Split('@')[0]; public string Hostname => Jid.Split('@')[1]; private readonly string password; public readonly List AllowedDomains = new List(); public string ReminderFilePath { get; set; } = "./reminders.xml"; private ReminderList reminderList = new ReminderList(); private XmppClient client; /// /// The number of seconds to wait before trying to reconnect to the /// server again if we loose our connection again. /// private int nextBackoffDelay = 1; private int defaultBackoffDelay = 1; private float backoffDelayMultiplier = 2; /// /// If a connection attempt doesn't succeed in this number of seconds, /// give up and try again later. /// private int giveUpTimeout = 30; 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}"); reminderList = ReminderList.FromXmlFile(ReminderFilePath); } client = new XmppClient(Hostname, Username, password); client.Error += errorHandler; client.Message += messageHandlerRoot; client.SubscriptionRequest += subscriptionRequestHandler; // 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 connect() { if (client.Connected) return true; DateTime startTime = DateTime.Now; client.Connect(); while (!client.Connected) { if ((DateTime.Now - startTime).TotalSeconds > giveUpTimeout) return false; await Task.Delay(100); } Console.WriteLine($"[Rhino/Setup] Connected as {Jid}"); OnConnected(this, new OnConnectedEventArgs()); return true; } #region XMPP Event Handling private bool subscriptionRequestHandler(Jid from) { if (!AllowedDomains.Contains("*") && !AllowedDomains.Contains(from.Domain)) { 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) { Console.Error.WriteLine($"[Error/Handler] Reconnecting in {TimeSpan.FromSeconds(nextBackoffDelay).ToString()}."); Thread.Sleep(nextBackoffDelay * 1000); Console.WriteLine("[Error/Handler] Attempting to reconnect to the server"); if (!connect().Result) nextBackoffDelay = (int)Math.Ceiling(nextBackoffDelay * backoffDelayMultiplier); else nextBackoffDelay = defaultBackoffDelay; } } 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); 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) "); } } #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)) { 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": sendChatReply(message, "Hello! I'm a reminder bot written by Starbeamrainbowlabs."); sendChatReply(message, "I can understand messages you send me in regular english."); 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."); sendChatReply(message, "I currently understand the following instructions:\n"); sendChatReply(message, "**Remind:** Set a reminder"); sendChatReply(message, "**List / Show:** List the reminders I have set"); sendChatReply(message, "**Delete / Remove:** Delete a reminder by it's number (find this in the reminder list from the instruction above)"); sendChatReply(message, "\nExample: 'Remind me to feed the cat tomorrow at 6pm'"); 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")}."; sendChatReply(message, response); } if (succeeded.Count > 0) { string response = string.Join(", ", succeeded.Select((int nextId) => $"#{nextId}")); response = $"Deleted reminder{(succeeded.Count != 1 ? "s" : "")} {response} successfully."; sendChatReply(message, response); } break; case "list": case "show": // Filter by reminders for this user. IEnumerable userReminderList = reminderList.Reminders.Values.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)"); sendChatReply(message, listMessage.ToString()); break; case "remind": 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 = messageText.Remove(dateStringLocation.Min, dateStringLocation.Stride) .ReplaceMultiple(new string[] { @"\s{2,}", @"^remind\s+(?:me\s+)?", @"^me\s+", @"^on\s+", @"my", @"you", @"your", @"&" // Ampersands cause a crash when sending! }, new string[] { " ", "", "", "", "your", @"me", @"my", @"and" }, RegexOptions.IgnoreCase).Trim(); sendChatReply(message, $"Ok! I'll remind you {reminder} at {dateTime}."); Reminder newReminder = reminderList.CreateReminder(message.From, dateTime, reminder); reminderList.Save(ReminderFilePath); break; default: sendChatReply(message, "I don't understand that. Try rephrasing it or asking for help."); break; } } #region Outgoing /// /// Sends a chat message to the specified JID. /// /// The JID to send the message to. /// The messaage to send. private void sendChatMessage(Jid to, string message) { //Console.WriteLine($"[Rhino/Send/Chat] Sending {message} -> {to}"); client.SendMessage( to, message, null, null, MessageType.Chat ); } /// /// Sends a chat message in direct reply to a given incoming message. /// /// Original message. /// Reply. 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 ); } #endregion #region Reminder Listening private async Task watchForReminders() { CancellationTokenSource cancellationSource = new CancellationTokenSource(); CancellationToken cancellationToken = cancellationSource.Token; Reminder nextReminder = reminderList.GetNextReminder(); reminderList.OnReminderListUpdate += (object sender, Reminder newReminder) => { 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; cancellationSource.Cancel(); } }; while (true) { TimeSpan nextWaitingTime; try { if (nextReminder != null) { nextWaitingTime = nextReminder.Time - DateTime.Now; 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}"); try { 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}"); sendChatMessage(nextReminder.Jid, "Oops! I encountered an error sending you a reminder. Please contact my operator!"); } 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)" ); } reminderList.DeleteReminder(nextReminder); reminderList.Save(ReminderFilePath); nextReminder = reminderList.GetNextReminder(); } } #endregion } }