diff --git a/README.md b/README.md index 36fd518..aeb414f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,14 @@ > An XMPP reminder bot written in C#. + ## Getting Started + +### Downloading Prebuilt Binaries +Prebuilt binaries for the latest release are available on the [releases page](https://git.starbeamrainbowlabs.com/sbrl/RhinoReminds/releases). + +### Building from Source + 1. Install the NuGet dependencies: ```bash @@ -32,6 +39,7 @@ mono [--debug] RhinoReminds.exe [--help] RhinoReminds.exe [--help] ``` + ## Usage The bot operates on natural language instructions. It picks what to do from the first word in the sentence, but the rest is parsed via AI. @@ -52,6 +60,25 @@ Show all List all ``` +### Deleting Reminders + +``` +Delete reminder 43 +Delete #22, #23, and #45 +Delete number eight +Delete reminders 2, 3, 4, and 7 +``` + + +## Contributing +Contributions are welcome! Bug reports can be opened against this repository if you have an account. Otherwise, send them to `bugs at starbeamrainbowlabs dot com`. + +Pull requests and patches are welcome too. [Here's a great tutorial](https://makandracards.com/makandra/2521-git-how-to-create-and-apply-patches) on creating patches. If there's any interest, I'll move this repository to my account on [gitlab.com](https://gitlab.com/sbrl) if that makes things easier. + + +## License +RhinoReminds is licensed under the _Mozilla Public License 2.0_ (MPL-2.0 for short) - the full text of which can be found in the [LICENSE](https://git.starbeamrainbowlabs.com/sbrl/RhinoReminds/src/branch/master/LICENSE) file in this repository. tl;drLegal have a [great summary](https://tldrlegal.com/license/mozilla-public-license-2.0-(mpl-2)) if you don't want to spend all day read dry legalese :P + ## Useful Links - [Microsoft.Text.Recognizers Samples](https://github.com/Microsoft/Recognizers-Text/tree/master/.NET/Samples) diff --git a/RhinoReminds/ClientListener.cs b/RhinoReminds/ClientListener.cs index aedab14..a045d9b 100644 --- a/RhinoReminds/ClientListener.cs +++ b/RhinoReminds/ClientListener.cs @@ -9,45 +9,49 @@ 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 readonly string Jid; - public string Username => Jid.Split('@')[0]; - public string Hostname => Jid.Split('@')[1]; + 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 XmppClient client; + private SimpleXmppClient client; /// - /// The number of seconds to wait before trying to reconnect to the + /// The initial 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; + 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 int giveUpTimeout = 30; + private readonly int giveUpTimeout = 30; public ClientListener(string inJid, string inPassword) { - Jid = inJid; + Jid = new Jid(inJid); password = inPassword; } @@ -59,26 +63,48 @@ namespace RhinoReminds 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); + 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.Connected) - return true; + if (client != null) { + if (client.Connected) { + return true; + } else { + client.Dispose(); + client = null; + } + } DateTime startTime = DateTime.Now; - client.Connect(); + client = new SimpleXmppClient(Jid, password); + client.Error += errorHandler; + client.Message += messageHandlerRoot; + client.SubscriptionRequest += subscriptionRequestHandler; + + client.Connect("RhinoReminds"); while (!client.Connected) { @@ -89,16 +115,25 @@ namespace RhinoReminds } Console.WriteLine($"[Rhino/Setup] Connected as {Jid}"); + OnConnected(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)) { - sendChatMessage(from, "Sorry! The domain of your JID doesn't match the ones in my allowed list."); + 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}"); @@ -109,17 +144,8 @@ namespace RhinoReminds { Console.Error.WriteLine($"[Error] {e.Reason}: {e.Exception}"); - if(!client.Connected) - { - 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; - + if(!client.Connected || e.Exception is IOException) { + reconnect().Wait(); } } @@ -134,8 +160,8 @@ namespace RhinoReminds 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) "); + 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) "); } } @@ -154,7 +180,7 @@ namespace RhinoReminds 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."); + client.SendChatMessage(message.From, "Sorry! The domain of your JID doesn't match the ones in my allowed list."); return; } @@ -166,15 +192,15 @@ namespace RhinoReminds 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 " + + 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."); - 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'"); + 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, "\nExample: 'Remind me to feed the cat tomorrow at 6pm'"); break; case "delete": @@ -195,19 +221,21 @@ namespace RhinoReminds 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); + 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."; - sendChatReply(message, response); + client.SendChatReply(message, response); } break; case "list": case "show": // Filter by reminders for this user. - IEnumerable userReminderList = reminderList.Reminders.Values.Where( + 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"); @@ -216,7 +244,7 @@ namespace RhinoReminds } listMessage.AppendLine(); listMessage.AppendLine($"({userReminderList.Count()} total)"); - sendChatReply(message, listMessage.ToString()); + client.SendChatReply(message, listMessage.ToString()); break; @@ -225,8 +253,8 @@ namespace RhinoReminds 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})"); + 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( @@ -239,70 +267,63 @@ namespace RhinoReminds @"^remind\s+(?:me\s+)?", @"^me\s+", @"^on\s+", - @"my", @"you", @"your", + @"my", @"&" // Ampersands cause a crash when sending! }, new string[] { " ", "", "", "", - "your", @"me", @"my", + "your", @"and" }, RegexOptions.IgnoreCase).Trim(); - sendChatReply(message, $"Ok! I'll remind you {reminder} at {dateTime}."); 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: - sendChatReply(message, "I don't understand that. Try rephrasing it or asking for help."); + client.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 void interruptReminderWatcher() + { + reminderWatcherReset?.Cancel(); + reminderWatcherReset = new CancellationTokenSource(); + } + private async Task watchForReminders() { - CancellationTokenSource cancellationSource = new CancellationTokenSource(); - CancellationToken cancellationToken = cancellationSource.Token; + interruptReminderWatcher(); Reminder nextReminder = reminderList.GetNextReminder(); + + // ----- Events ----- + // This will run on the firing thread, not on this thread reminderList.OnReminderListUpdate += (object sender, Reminder newReminder) => { Reminder newNextReminder = reminderList.GetNextReminder(); //Console.WriteLine("[Rhino/Reminderd/Canceller] Reminder added - comparing."); @@ -310,65 +331,76 @@ namespace RhinoReminds if (nextReminder != newNextReminder) { //Console.WriteLine($"[Rhino/Reminderd/Canceller] Cancelling"); nextReminder = newNextReminder; - cancellationSource.Cancel(); + interruptReminderWatcher(); } }; + // ------------------ - while (true) - { + 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) { + if (DateTime.Now < nextReminder.Time) + { //Console.WriteLine($"[Rhino/Reminderd] Sleeping for {nextWaitingTime}"); - await Task.Delay(nextWaitingTime, cancellationToken); + await Task.Delay(nextWaitingTime, reminderWatcherResetToken); } - } else { - //Console.WriteLine("[Rhino/Reminderd] Sleeping until interrupted"); - await Task.Delay(Timeout.Infinite, cancellationToken); } - } catch (TaskCanceledException) { + else { + //Console.WriteLine("[Rhino/Reminderd] Sleeping until interrupted"); + await Task.Delay(Timeout.Infinite, reminderWatcherResetToken); + } + } + catch (TaskCanceledException) + { //Console.WriteLine("[Rhino/Reminderd] Sleep interrupted, recalculating"); - cancellationSource = new CancellationTokenSource(); - cancellationToken = cancellationSource.Token; + interruptReminderWatcher(); continue; } - if (cancellationToken.IsCancellationRequested) { + if (reminderWatcherResetToken.IsCancellationRequested) + { Console.WriteLine("[Rhino/Reminderd] Sleep interrupted, recalculating (but no exception thrown)"); - cancellationSource = new CancellationTokenSource(); - cancellationToken = cancellationSource.Token; - + interruptReminderWatcher(); continue; } Console.WriteLine($"[Rhino/Reminderd] Sending notification {nextReminder}"); + sendAndDeleteReminder(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( + client.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(); } } + 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 } } diff --git a/RhinoReminds/CompareReminders.cs b/RhinoReminds/CompareReminders.cs new file mode 100644 index 0000000..cffb64e --- /dev/null +++ b/RhinoReminds/CompareReminders.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace RhinoReminds +{ + public class CompareReminders : IComparer + { + public CompareReminders() + { + } + + public int Compare(Reminder x, Reminder y) + { + return (int)(x.Time - y.Time).TotalMilliseconds; + } + } +} diff --git a/RhinoReminds/Program.cs b/RhinoReminds/Program.cs index b812492..a3ae593 100644 --- a/RhinoReminds/Program.cs +++ b/RhinoReminds/Program.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Threading.Tasks; using S22.Xmpp; @@ -15,6 +16,7 @@ namespace RhinoReminds public string Jid = null; public string AvatarFilepath = string.Empty; + public string PidFile = null; public string Password = null; } @@ -47,7 +49,8 @@ namespace RhinoReminds Console.WriteLine(" -h --help Show this message"); Console.WriteLine($" -f --file Specify where to save reminders (default: {settings.Filepath})"); Console.WriteLine(" --domain {domain} Set the domain users are allowed to originate at. Defaults to any domain."); - Console.WriteLine(" --avatar Update the XMPP account's avatar to the specified image. By default the avatar is not updated."); + Console.WriteLine(" --avatar Update the XMPP account's avatar to the specified image. By default the avatar is not updated."); + Console.WriteLine(" --pidfile Save our process ID to the specified file, and delete it on exit"); Console.WriteLine(); Console.WriteLine("Environment Variables:"); Console.WriteLine(" XMPP_JID The JID to login to"); @@ -67,30 +70,84 @@ namespace RhinoReminds case "--avatar": settings.AvatarFilepath = args[++i]; break; + + case "--pidfile": + settings.PidFile = args[++i]; + break; + + default: + Console.Error.WriteLine($"Error: Unknown argument '{args[i]}'."); + return 14; } } settings.Jid = Environment.GetEnvironmentVariable("XMPP_JID"); settings.Password = Environment.GetEnvironmentVariable("XMPP_PASSWORD"); - Run(); + if (settings.Jid == null) { + Console.Error.WriteLine("Error: No JID specified to login with."); + Console.Error.WriteLine("Do so with the XMPP_JID environment variable!"); + return 15; + } + if (settings.Password == null) { + Console.Error.WriteLine("Error: No password specified to login with."); + Console.Error.WriteLine("Do so with the XMPP_PASSWORD environment variable!"); + return 16; + } + if (settings.PidFile != null) + setupPidFile(); + + run(); + // We shouldn't ever end up here, but just in case..... + cleanupPidFile(); return 0; } - public static void Run() + private static void setupPidFile() + { + File.WriteAllText(settings.PidFile, Process.GetCurrentProcess().Id.ToString()); + AppDomain.CurrentDomain.ProcessExit += (object sender, EventArgs e) => cleanupPidFile(); + AppDomain.CurrentDomain.DomainUnload += (object sender, EventArgs e) => cleanupPidFile(); + } + + private static void cleanupPidFile() { + // Make sure we only do cleanup once + if (settings.PidFile == null) + return; + + File.Delete(settings.PidFile); + settings.PidFile = null; + } + + private static void run() { ClientListener client = new ClientListener(settings.Jid, settings.Password) { ReminderFilePath = settings.Filepath }; client.AllowedDomains.Add(settings.AllowedDomain); - // Connect to the server & start listening - Task clientListener = client.Start(); // Update the avatar if appropriate - if(settings.AvatarFilepath != string.Empty) - client.SetAvatar(settings.AvatarFilepath); + if (settings.AvatarFilepath != string.Empty) { + OnConnectedHandler handler = null; + handler = (object sender, OnConnectedEventArgs eventArgs) => { + client.SetAvatar(settings.AvatarFilepath); + Console.WriteLine($"[Program] Set avatar to '{settings.AvatarFilepath}'."); + client.OnConnected -= handler; + }; + client.OnConnected += handler; + } + // Connect to the server & start listening // Make sure the program doesn't exit whilst we're connected - clientListener.Wait(); + try + { + client.Start().Wait(); + } catch (Exception) { + // Ensure we tidy up after ourselves by deleting the PID file + if (settings.PidFile != null) + cleanupPidFile(); + // Re-throw the error + throw; + } } } } diff --git a/RhinoReminds/Reminder.cs b/RhinoReminds/Reminder.cs index 682e3cf..ae2b4d9 100644 --- a/RhinoReminds/Reminder.cs +++ b/RhinoReminds/Reminder.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net; using System.Xml; using S22.Xmpp; @@ -6,29 +7,59 @@ using S22.Xmpp; namespace RhinoReminds { - public class Reminder + public class Reminder : IEquatable { public int Id { get; } - public string Jid { get; } - public Jid JidObj => new Jid(Jid); - public DateTime Time { get; } + public string Jid => JidObj.ToString(); + public Jid JidObj { get; } + public DateTime Time { get; private set; } public string Message { get; } - public Reminder(int inId, string inJid, DateTime inTime, string inMessage) + public Reminder(int inId, Jid inJid, DateTime inTime, string inMessage) { Id = inId; - Jid = inJid; + JidObj = inJid.GetBareJid(); Time = inTime; Message = inMessage; } - public override string ToString() - { - return $"[Reminder Id={Id}, Jid={Jid}, Time={Time}, Message={Message}"; + public void TweakTime() { + Time = Time.AddMilliseconds(1); } - #region XML + #region Overrides + + public override bool Equals(object obj) { + if (!(obj is Reminder)) return false; + Reminder otherReminder = obj as Reminder; + return otherReminder.Id == Id && + otherReminder.JidObj == JidObj && // Will *always* be a bare Jid + otherReminder.Time == Time && + otherReminder.Message == Message; + } + // For IEquatable implementation + public bool Equals(Reminder otherReminder) => Equals((object)otherReminder); + + public override int GetHashCode() { + int hashCode = -81903051; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(JidObj); + hashCode = (hashCode * -1521134295) + Time.GetHashCode(); + hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(Message); + return hashCode; + } + public static bool operator ==(Reminder reminder1, Reminder reminder2) => EqualityComparer.Default.Equals(reminder1, reminder2); + public static bool operator !=(Reminder reminder1, Reminder reminder2) => !(reminder1 == reminder2); + + public override string ToString() { + return $"[Reminder Id={Id}, Jid={Jid}, Time={Time}, Message=\"{Message}\"]"; + } + + #endregion + + + #region XML public void WriteToXml(XmlWriter xml) { @@ -66,7 +97,8 @@ namespace RhinoReminds return new Reminder(id, jid, dateTime, message); } - #endregion + + } } diff --git a/RhinoReminds/ReminderList.cs b/RhinoReminds/ReminderList.cs index adc401c..933acbe 100644 --- a/RhinoReminds/ReminderList.cs +++ b/RhinoReminds/ReminderList.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Xml; using S22.Xmpp; @@ -14,7 +13,7 @@ namespace RhinoReminds private int nextId = 0; private readonly object saveLock = new object(); - public SortedList Reminders = new SortedList(); + public SortedSet Reminders = new SortedSet(new CompareReminders()); public event OnReminderListUpdateHandler OnReminderListUpdate; @@ -29,29 +28,28 @@ namespace RhinoReminds { Reminder result = new Reminder(nextId++, $"{inJid.Node}@{inJid.Domain}", time, message); Console.WriteLine($"[Rhino/ReminderList] Created reminder {result}"); - while (Reminders.ContainsKey(time)) - time = time.AddMilliseconds(1); - Reminders.Add(time, result); + while (Reminders.Contains(result)) + result.TweakTime(); + + Reminders.Add(result); OnReminderListUpdate(this, result); return result; } - public Reminder GetNextReminder() - { + public Reminder GetNextReminder() { if (Reminders.Count == 0) return null; - return Reminders.Values[0]; + return Reminders.Min; } - public Reminder GetById(int targetId) - { - return Reminders.First((KeyValuePair nextPair) => nextPair.Value.Id == targetId).Value; + public Reminder GetById(int targetId) { + return Reminders.First((Reminder nextReminder) => nextReminder.Id == targetId); } - public void DeleteReminder(Reminder nextReminder) - { - Reminders.Remove(nextReminder.Time); + public void DeleteReminder(Reminder nextReminder) { + if (!Reminders.Remove(nextReminder)) + throw new ApplicationException($"Error: Failed to remove the reminder {nextReminder} from the list!"); } @@ -62,7 +60,7 @@ namespace RhinoReminds // 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 + { // FUTURE: We could go lockless here with some work, but it's not worth it for the teensy chance & low overhead XmlWriter xml = XmlWriter.Create( filename, new XmlWriterSettings() { Indent = true } @@ -74,7 +72,7 @@ namespace RhinoReminds xml.WriteElementString("NextId", nextId.ToString()); xml.WriteStartElement("Reminders"); - foreach (Reminder nextReminder in Reminders.Values) + foreach (Reminder nextReminder in Reminders) nextReminder.WriteToXml(xml); xml.WriteEndElement(); @@ -89,15 +87,15 @@ namespace RhinoReminds XmlDocument xml = new XmlDocument(); xml.Load(filepath); - ReminderList result = new ReminderList(); - result.nextId = int.Parse(xml.GetElementsByTagName("NextId")[0].InnerText); - foreach (XmlNode reminderXML in xml.GetElementsByTagName("Reminders")[0].ChildNodes) - { + ReminderList result = new ReminderList() { + nextId = int.Parse(xml.GetElementsByTagName("NextId")[0].InnerText) + }; + + foreach (XmlNode reminderXML in xml.GetElementsByTagName("Reminders")[0].ChildNodes) { Reminder nextReminder = Reminder.FromXml(reminderXML); - DateTime timeKey = nextReminder.Time; - while (result.Reminders.ContainsKey(timeKey)) - timeKey = timeKey.AddMilliseconds(1); - result.Reminders.Add(timeKey, nextReminder); + while (result.Reminders.Contains(nextReminder)) + nextReminder.TweakTime(); + result.Reminders.Add(nextReminder); } return result; diff --git a/RhinoReminds/RhinoReminds.csproj b/RhinoReminds/RhinoReminds.csproj index bf2fddc..3dd6e4c 100644 --- a/RhinoReminds/RhinoReminds.csproj +++ b/RhinoReminds/RhinoReminds.csproj @@ -43,19 +43,19 @@ - ..\packages\Microsoft.Recognizers.Text.1.1.3\lib\net462\Microsoft.Recognizers.Definitions.dll + ..\packages\Microsoft.Recognizers.Text.1.1.4\lib\net462\Microsoft.Recognizers.Definitions.dll - ..\packages\Microsoft.Recognizers.Text.1.1.3\lib\net462\Microsoft.Recognizers.Text.dll + ..\packages\Microsoft.Recognizers.Text.1.1.4\lib\net462\Microsoft.Recognizers.Text.dll - ..\packages\Microsoft.Recognizers.Text.Number.1.1.3\lib\net462\Microsoft.Recognizers.Text.Number.dll + ..\packages\Microsoft.Recognizers.Text.Number.1.1.4\lib\net462\Microsoft.Recognizers.Text.Number.dll - ..\packages\Microsoft.Recognizers.Text.NumberWithUnit.1.1.3\lib\net462\Microsoft.Recognizers.Text.NumberWithUnit.dll + ..\packages\Microsoft.Recognizers.Text.NumberWithUnit.1.1.4\lib\net462\Microsoft.Recognizers.Text.NumberWithUnit.dll - ..\packages\Microsoft.Recognizers.Text.DateTime.1.1.3\lib\net462\Microsoft.Recognizers.Text.DateTime.dll + ..\packages\Microsoft.Recognizers.Text.DateTime.1.1.4\lib\net462\Microsoft.Recognizers.Text.DateTime.dll @@ -68,6 +68,8 @@ + + diff --git a/RhinoReminds/SimpleXmppClient.cs b/RhinoReminds/SimpleXmppClient.cs new file mode 100644 index 0000000..4c180f6 --- /dev/null +++ b/RhinoReminds/SimpleXmppClient.cs @@ -0,0 +1,42 @@ +using System; +using S22.Xmpp; +using S22.Xmpp.Client; +using S22.Xmpp.Im; + +namespace RhinoReminds +{ + public class SimpleXmppClient : XmppClient + { + public SimpleXmppClient(Jid user, string password) : base(user.Domain, user.Node, password) + { + } + + /// + /// Sends a chat message to the specified JID. + /// + /// The JID to send the message to. + /// The messaage to send. + public void SendChatMessage(Jid to, string message) + { + //Console.WriteLine($"[Rhino/Send/Chat] Sending {message} -> {to}"); + SendMessage( + to, message, + null, null, MessageType.Chat + ); + } + /// + /// Sends a chat message in direct reply to a given incoming message. + /// + /// Original message. + /// Reply. + public void SendChatReply(Message originalMessage, string reply) + { + //Console.WriteLine($"[Rhino/Send/Reply] Sending {reply} -> {originalMessage.From}"); + SendMessage( + originalMessage.From, reply, + null, originalMessage.Thread, MessageType.Chat + ); + } + + } +} diff --git a/RhinoReminds/packages.config b/RhinoReminds/packages.config index e51b60d..c938836 100644 --- a/RhinoReminds/packages.config +++ b/RhinoReminds/packages.config @@ -1,9 +1,9 @@  - - - - + + + + diff --git a/rhinoreminds.service b/rhinoreminds.service index cced202..87c84f9 100644 --- a/rhinoreminds.service +++ b/rhinoreminds.service @@ -2,15 +2,53 @@ Description=RhinoReminds XMPP Bot After=network.target prosody.service +# No more than 5 crashes in 12 hours +StartLimitIntervalSec=43200 +StartLimitBurst=5 + [Service] -Type=simple -# Another Type option: forking -User=rhinoreminds -WorkingDirectory=/srv/rhinoreminds -ExecStart=/srv/rhinoreminds/start_service.sh +Type=forking +PIDFile=/run/rhinoreminds/rhinoreminds.pid +# We change our own user +User=root +WorkingDirectory=/srv/kraggwapple +ExecStart=/srv/kraggwapple/start_service.sh Restart=on-failure # Other Restart options: or always, on-abort, etc +# Delay restarts by 60 seconds +RestartSec=60 + +[Install] +WantedBy=multi-user.target + + + + + + + + + +[Unit] +Description=Kraggwapple XMPP Bot +After=network.target prosody.service + +# No more than 5 crashes in 12 hours +StartLimitIntervalSec=43200 +StartLimitBurst=5 + +[Service] +Type=forking +PIDFile=/run/kraggwapple.pid +# We change our own user +User=root +WorkingDirectory=/srv/kraggwapple +ExecStart=/srv/kraggwapple/start_service.sh +Restart=on-failure +# Other Restart options: or always, on-abort, etc +# Delay restarts by 60 seconds +RestartSec=60 # If you want logs to be kept automatically in /var/log, uncomment the following & copy "rhinoreminds-rsyslog.conf" in the root of this repository to "/etc/rsyslog.d" - and then do `sudo systemctl restart rsyslog.d` #StandardOutput=syslog @@ -19,3 +57,8 @@ Restart=on-failure [Install] WantedBy=multi-user.target + + + + + diff --git a/start_service.sh b/start_service.sh index c75201b..c2c64d6 100755 --- a/start_service.sh +++ b/start_service.sh @@ -1,8 +1,15 @@ #!/usr/bin/env bash -source .xmpp_credentials +cd data; +source .xmpp_credentials; +# Execute & disown +# We pass the environment variables explicitly here, as then we don't accidentally pass something private. +# Better to be safe than sorry - defence in depth! export XMPP_JID; export XMPP_PASSWORD; -exec /usr/bin/mono RhinoReminds.exe --domain starbeamrainbowlabs.com +# Create the pidfile directory +mkdir /run/rhinoreminds; chmod 0700 /run/rhinoreminds; chown rhinoreminds:rhinoreminds /run/rhinoreminds; + +sudo -E -u rhinoreminds bash -c '/usr/bin/mono ../bin/RhinoReminds.exe --domain starbeamrainbowlabs.com --avatar avatar.png & echo "$!" >/run/rhinoreminds/rhinoreminds.pid; disown'