Merge branch 'master' of git.starbeamrainbowlabs.com:sbrl/RhinoReminds

This commit is contained in:
Starbeamrainbowlabs 2019-02-04 13:54:34 +00:00
commit 6f9ff7eef0
Signed by: sbrl
GPG Key ID: 1BE5172E637709C2
11 changed files with 427 additions and 170 deletions

View File

@ -2,7 +2,14 @@
> An XMPP reminder bot written in C#. > An XMPP reminder bot written in C#.
## Getting Started ## 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: 1. Install the NuGet dependencies:
```bash ```bash
@ -32,6 +39,7 @@ mono [--debug] RhinoReminds.exe [--help]
RhinoReminds.exe [--help] RhinoReminds.exe [--help]
``` ```
## Usage ## 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. 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 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 ## Useful Links
- [Microsoft.Text.Recognizers Samples](https://github.com/Microsoft/Recognizers-Text/tree/master/.NET/Samples) - [Microsoft.Text.Recognizers Samples](https://github.com/Microsoft/Recognizers-Text/tree/master/.NET/Samples)

View File

@ -9,45 +9,49 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using RhinoReminds.Utilities; using RhinoReminds.Utilities;
using S22.Xmpp; using S22.Xmpp;
using S22.Xmpp.Client;
using S22.Xmpp.Im; using S22.Xmpp.Im;
using SBRL.Geometry; using SBRL.Geometry;
namespace RhinoReminds namespace RhinoReminds
{ {
public class OnConnectedEventArgs : EventArgs { }
public delegate void OnConnectedHandler(object sender, OnConnectedEventArgs eventArgs);
public class ClientListener public class ClientListener
{ {
public bool Debug { get; set; } = false; public bool Debug { get; set; } = false;
public readonly string Jid; public event OnConnectedHandler OnConnected;
public string Username => Jid.Split('@')[0];
public string Hostname => Jid.Split('@')[1]; public readonly Jid Jid;
public string Username => Jid.Node;
public string Hostname => Jid.Domain;
private readonly string password; private readonly string password;
public readonly List<string> AllowedDomains = new List<string>(); public readonly List<string> AllowedDomains = new List<string>();
public string ReminderFilePath { get; set; } = "./reminders.xml"; public string ReminderFilePath { get; set; } = "./reminders.xml";
private ReminderList reminderList = new ReminderList(); private ReminderList reminderList = new ReminderList();
private CancellationTokenSource reminderWatcherReset;
private CancellationToken reminderWatcherResetToken => reminderWatcherReset.Token;
private SimpleXmppClient client;
private XmppClient client;
/// <summary> /// <summary>
/// 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. /// server again if we loose our connection again.
/// </summary> /// </summary>
private int nextBackoffDelay = 1; private readonly int defaultBackoffDelay = 1;
private int defaultBackoffDelay = 1; private readonly float backoffDelayMultiplier = 2;
private float backoffDelayMultiplier = 2;
/// <summary> /// <summary>
/// If a connection attempt doesn't succeed in this number of seconds, /// If a connection attempt doesn't succeed in this number of seconds,
/// give up and try again later. /// give up and try again later.
/// </summary> /// </summary>
private int giveUpTimeout = 30; private readonly int giveUpTimeout = 30;
public ClientListener(string inJid, string inPassword) public ClientListener(string inJid, string inPassword)
{ {
Jid = inJid; Jid = new Jid(inJid);
password = inPassword; password = inPassword;
} }
@ -59,26 +63,48 @@ namespace RhinoReminds
reminderList = ReminderList.FromXmlFile(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 // Connect to the server. This starts it's own thread that doesn't block the program exiting, apparently
await connect(); await connect();
//client.SetStatus(Availability.Online); client.SetStatus(Availability.Online);
await watchForReminders(); 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<bool> connect() private async Task<bool> connect()
{ {
if (client.Connected) if (client != null) {
return true; if (client.Connected) {
return true;
} else {
client.Dispose();
client = null;
}
}
DateTime startTime = DateTime.Now; 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) while (!client.Connected)
{ {
@ -89,16 +115,25 @@ namespace RhinoReminds
} }
Console.WriteLine($"[Rhino/Setup] Connected as {Jid}"); Console.WriteLine($"[Rhino/Setup] Connected as {Jid}");
OnConnected(this, new OnConnectedEventArgs());
return true; return true;
} }
private void disconnect()
{
client.Close();
client.Dispose();
client = null;
Console.WriteLine($"[Rhino] Disconnected from server.");
}
#region XMPP Event Handling #region XMPP Event Handling
private bool subscriptionRequestHandler(Jid from) private bool subscriptionRequestHandler(Jid from)
{ {
if (!AllowedDomains.Contains("*") && !AllowedDomains.Contains(from.Domain)) { 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; return false;
} }
Console.WriteLine($"[Rhino/SubscriptionRequest] Approving subscription from {from}"); Console.WriteLine($"[Rhino/SubscriptionRequest] Approving subscription from {from}");
@ -109,17 +144,8 @@ namespace RhinoReminds
{ {
Console.Error.WriteLine($"[Error] {e.Reason}: {e.Exception}"); Console.Error.WriteLine($"[Error] {e.Reason}: {e.Exception}");
if(!client.Connected) if(!client.Connected || e.Exception is IOException) {
{ reconnect().Wait();
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;
} }
} }
@ -134,8 +160,8 @@ namespace RhinoReminds
catch (Exception error) catch (Exception error)
{ {
Console.Error.WriteLine(error); Console.Error.WriteLine(error);
sendChatReply(eventArgs.Message, "Oops! I encountered an error. Please report this to my operator!"); client.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, $"Technical details: {WebUtility.HtmlEncode(error.ToString())} (stack trace available in server log) ");
} }
} }
@ -154,7 +180,7 @@ namespace RhinoReminds
private void messageHandler(Message message) private void messageHandler(Message message)
{ {
if (!AllowedDomains.Contains("*") && !AllowedDomains.Contains(message.From.Domain)) { 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; return;
} }
@ -166,15 +192,15 @@ namespace RhinoReminds
switch (parts[0].ToLower()) switch (parts[0].ToLower())
{ {
case "help": case "help":
sendChatReply(message, "Hello! I'm a reminder bot written by Starbeamrainbowlabs."); client.SendChatReply(message, "Hello! I'm a reminder bot written by Starbeamrainbowlabs.");
sendChatReply(message, "I can understand messages you send me in regular english."); client.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, "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."); "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"); client.SendChatReply(message, "I currently understand the following instructions:\n");
sendChatReply(message, "**Remind:** Set a reminder"); client.SendChatReply(message, "**Remind:** Set a reminder");
sendChatReply(message, "**List / Show:** List the reminders I have set"); client.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)"); client.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, "\nExample: 'Remind me to feed the cat tomorrow at 6pm'");
break; break;
case "delete": case "delete":
@ -195,19 +221,21 @@ namespace RhinoReminds
if (failed.Count > 0) { if (failed.Count > 0) {
string response = string.Join(", ", failed.Select((int nextId) => $"#{nextId}")); 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")}."; 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) { if (succeeded.Count > 0) {
// Ensure that the reminder thread picks up the changes
interruptReminderWatcher();
string response = string.Join(", ", succeeded.Select((int nextId) => $"#{nextId}")); string response = string.Join(", ", succeeded.Select((int nextId) => $"#{nextId}"));
response = $"Deleted reminder{(succeeded.Count != 1 ? "s" : "")} {response} successfully."; response = $"Deleted reminder{(succeeded.Count != 1 ? "s" : "")} {response} successfully.";
sendChatReply(message, response); client.SendChatReply(message, response);
} }
break; break;
case "list": case "list":
case "show": case "show":
// Filter by reminders for this user. // Filter by reminders for this user.
IEnumerable<Reminder> userReminderList = reminderList.Reminders.Values.Where( IEnumerable<Reminder> userReminderList = reminderList.Reminders.Where(
(Reminder next) => message.From.GetBareJid() == next.JidObj.GetBareJid() (Reminder next) => message.From.GetBareJid() == next.JidObj.GetBareJid()
); );
StringBuilder listMessage = new StringBuilder("I've got the following reminders on my list:\n"); StringBuilder listMessage = new StringBuilder("I've got the following reminders on my list:\n");
@ -216,7 +244,7 @@ namespace RhinoReminds
} }
listMessage.AppendLine(); listMessage.AppendLine();
listMessage.AppendLine($"({userReminderList.Count()} total)"); listMessage.AppendLine($"({userReminderList.Count()} total)");
sendChatReply(message, listMessage.ToString()); client.SendChatReply(message, listMessage.ToString());
break; break;
@ -225,8 +253,8 @@ namespace RhinoReminds
try { try {
dateTime = AIRecogniser.RecogniseDateTime(messageText, out rawDateTimeString); dateTime = AIRecogniser.RecogniseDateTime(messageText, out rawDateTimeString);
} catch (AIException error) { } catch (AIException error) {
sendChatReply(message, "Sorry, I had trouble figuring out when you wanted reminding about that!"); client.SendChatReply(message, "Sorry, I had trouble figuring out when you wanted reminding about that!");
sendChatReply(message, $"(Technical details: {error.Message})"); client.SendChatReply(message, $"(Technical details: {error.Message})");
return; return;
} }
Range dateStringLocation = new Range( Range dateStringLocation = new Range(
@ -239,70 +267,63 @@ namespace RhinoReminds
@"^remind\s+(?:me\s+)?", @"^remind\s+(?:me\s+)?",
@"^me\s+", @"^me\s+",
@"^on\s+", @"^on\s+",
@"my",
@"you", @"you",
@"your", @"your",
@"my",
@"&" // Ampersands cause a crash when sending! @"&" // Ampersands cause a crash when sending!
}, new string[] { }, new string[] {
" ", " ",
"", "",
"", "",
"", "",
"your",
@"me", @"me",
@"my", @"my",
"your",
@"and" @"and"
}, RegexOptions.IgnoreCase).Trim(); }, RegexOptions.IgnoreCase).Trim();
sendChatReply(message, $"Ok! I'll remind you {reminder} at {dateTime}.");
Reminder newReminder = reminderList.CreateReminder(message.From, dateTime, reminder); 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); reminderList.Save(ReminderFilePath);
break; break;
default: 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; break;
} }
} }
#region Outgoing #region Outgoing
/// <summary>
/// Sends a chat message to the specified JID.
/// </summary>
/// <param name="to">The JID to send the message to.</param>
/// <param name="message">The messaage to send.</param>
private void sendChatMessage(Jid to, string message) {
//Console.WriteLine($"[Rhino/Send/Chat] Sending {message} -> {to}");
client.SendMessage(
to, message,
null, null, MessageType.Chat
);
}
/// <summary>
/// Sends a chat message in direct reply to a given incoming message.
/// </summary>
/// <param name="originalMessage">Original message.</param>
/// <param name="reply">Reply.</param>
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 #endregion
#region Reminder Listening #region Reminder Listening
private void interruptReminderWatcher()
{
reminderWatcherReset?.Cancel();
reminderWatcherReset = new CancellationTokenSource();
}
private async Task watchForReminders() private async Task watchForReminders()
{ {
CancellationTokenSource cancellationSource = new CancellationTokenSource(); interruptReminderWatcher();
CancellationToken cancellationToken = cancellationSource.Token;
Reminder nextReminder = reminderList.GetNextReminder(); Reminder nextReminder = reminderList.GetNextReminder();
// ----- Events -----
// This will run on the firing thread, not on this thread
reminderList.OnReminderListUpdate += (object sender, Reminder newReminder) => { reminderList.OnReminderListUpdate += (object sender, Reminder newReminder) => {
Reminder newNextReminder = reminderList.GetNextReminder(); Reminder newNextReminder = reminderList.GetNextReminder();
//Console.WriteLine("[Rhino/Reminderd/Canceller] Reminder added - comparing."); //Console.WriteLine("[Rhino/Reminderd/Canceller] Reminder added - comparing.");
@ -310,65 +331,76 @@ namespace RhinoReminds
if (nextReminder != newNextReminder) { if (nextReminder != newNextReminder) {
//Console.WriteLine($"[Rhino/Reminderd/Canceller] Cancelling"); //Console.WriteLine($"[Rhino/Reminderd/Canceller] Cancelling");
nextReminder = newNextReminder; nextReminder = newNextReminder;
cancellationSource.Cancel(); interruptReminderWatcher();
} }
}; };
// ------------------
while (true) while (true) {
{ nextReminder = reminderList.GetNextReminder();
// Wait for the next reminder
TimeSpan nextWaitingTime; TimeSpan nextWaitingTime;
try { try {
if (nextReminder != null) { if (nextReminder != null) {
nextWaitingTime = nextReminder.Time - DateTime.Now; nextWaitingTime = nextReminder.Time - DateTime.Now;
if (DateTime.Now < nextReminder.Time) { if (DateTime.Now < nextReminder.Time)
{
//Console.WriteLine($"[Rhino/Reminderd] Sleeping for {nextWaitingTime}"); //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"); //Console.WriteLine("[Rhino/Reminderd] Sleep interrupted, recalculating");
cancellationSource = new CancellationTokenSource(); interruptReminderWatcher();
cancellationToken = cancellationSource.Token;
continue; continue;
} }
if (cancellationToken.IsCancellationRequested) { if (reminderWatcherResetToken.IsCancellationRequested)
{
Console.WriteLine("[Rhino/Reminderd] Sleep interrupted, recalculating (but no exception thrown)"); Console.WriteLine("[Rhino/Reminderd] Sleep interrupted, recalculating (but no exception thrown)");
cancellationSource = new CancellationTokenSource(); interruptReminderWatcher();
cancellationToken = cancellationSource.Token;
continue; continue;
} }
Console.WriteLine($"[Rhino/Reminderd] Sending notification {nextReminder}"); 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) { if (nextWaitingTime.TotalMilliseconds < 0) {
sendChatMessage( client.SendChatMessage(
nextReminder.Jid, nextReminder.Jid,
"(Sorry I'm late reminding you! I might not have been running at the time, " + "(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)" "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 #endregion
} }
} }

View File

@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
namespace RhinoReminds
{
public class CompareReminders : IComparer<Reminder>
{
public CompareReminders()
{
}
public int Compare(Reminder x, Reminder y)
{
return (int)(x.Time - y.Time).TotalMilliseconds;
}
}
}

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using S22.Xmpp; using S22.Xmpp;
@ -15,6 +16,7 @@ namespace RhinoReminds
public string Jid = null; public string Jid = null;
public string AvatarFilepath = string.Empty; public string AvatarFilepath = string.Empty;
public string PidFile = null;
public string Password = null; public string Password = null;
} }
@ -47,7 +49,8 @@ namespace RhinoReminds
Console.WriteLine(" -h --help Show this message"); Console.WriteLine(" -h --help Show this message");
Console.WriteLine($" -f --file Specify where to save reminders (default: {settings.Filepath})"); 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(" --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();
Console.WriteLine("Environment Variables:"); Console.WriteLine("Environment Variables:");
Console.WriteLine(" XMPP_JID The JID to login to"); Console.WriteLine(" XMPP_JID The JID to login to");
@ -67,30 +70,84 @@ namespace RhinoReminds
case "--avatar": case "--avatar":
settings.AvatarFilepath = args[++i]; settings.AvatarFilepath = args[++i];
break; 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.Jid = Environment.GetEnvironmentVariable("XMPP_JID");
settings.Password = Environment.GetEnvironmentVariable("XMPP_PASSWORD"); 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; 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) { ClientListener client = new ClientListener(settings.Jid, settings.Password) {
ReminderFilePath = settings.Filepath ReminderFilePath = settings.Filepath
}; };
client.AllowedDomains.Add(settings.AllowedDomain); client.AllowedDomains.Add(settings.AllowedDomain);
// Connect to the server & start listening
Task clientListener = client.Start();
// Update the avatar if appropriate // Update the avatar if appropriate
if(settings.AvatarFilepath != string.Empty) if (settings.AvatarFilepath != string.Empty) {
client.SetAvatar(settings.AvatarFilepath); 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 // 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;
}
} }
} }
} }

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Net; using System.Net;
using System.Xml; using System.Xml;
using S22.Xmpp; using S22.Xmpp;
@ -6,29 +7,59 @@ using S22.Xmpp;
namespace RhinoReminds namespace RhinoReminds
{ {
public class Reminder public class Reminder : IEquatable<Reminder>
{ {
public int Id { get; } public int Id { get; }
public string Jid { get; } public string Jid => JidObj.ToString();
public Jid JidObj => new Jid(Jid); public Jid JidObj { get; }
public DateTime Time { get; } public DateTime Time { get; private set; }
public string Message { get; } 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; Id = inId;
Jid = inJid; JidObj = inJid.GetBareJid();
Time = inTime; Time = inTime;
Message = inMessage; Message = inMessage;
} }
public override string ToString() public void TweakTime() {
{ Time = Time.AddMilliseconds(1);
return $"[Reminder Id={Id}, Jid={Jid}, Time={Time}, Message={Message}";
} }
#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<Reminder> 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<Jid>.Default.GetHashCode(JidObj);
hashCode = (hashCode * -1521134295) + Time.GetHashCode();
hashCode = (hashCode * -1521134295) + EqualityComparer<string>.Default.GetHashCode(Message);
return hashCode;
}
public static bool operator ==(Reminder reminder1, Reminder reminder2) => EqualityComparer<Reminder>.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) public void WriteToXml(XmlWriter xml)
{ {
@ -66,7 +97,8 @@ namespace RhinoReminds
return new Reminder(id, jid, dateTime, message); return new Reminder(id, jid, dateTime, message);
} }
#endregion #endregion
} }
} }

View File

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Xml; using System.Xml;
using S22.Xmpp; using S22.Xmpp;
@ -14,7 +13,7 @@ namespace RhinoReminds
private int nextId = 0; private int nextId = 0;
private readonly object saveLock = new object(); private readonly object saveLock = new object();
public SortedList<DateTime, Reminder> Reminders = new SortedList<DateTime, Reminder>(); public SortedSet<Reminder> Reminders = new SortedSet<Reminder>(new CompareReminders());
public event OnReminderListUpdateHandler OnReminderListUpdate; public event OnReminderListUpdateHandler OnReminderListUpdate;
@ -29,29 +28,28 @@ namespace RhinoReminds
{ {
Reminder result = new Reminder(nextId++, $"{inJid.Node}@{inJid.Domain}", time, message); Reminder result = new Reminder(nextId++, $"{inJid.Node}@{inJid.Domain}", time, message);
Console.WriteLine($"[Rhino/ReminderList] Created reminder {result}"); Console.WriteLine($"[Rhino/ReminderList] Created reminder {result}");
while (Reminders.ContainsKey(time)) while (Reminders.Contains(result))
time = time.AddMilliseconds(1); result.TweakTime();
Reminders.Add(time, result);
Reminders.Add(result);
OnReminderListUpdate(this, result); OnReminderListUpdate(this, result);
return result; return result;
} }
public Reminder GetNextReminder() public Reminder GetNextReminder() {
{
if (Reminders.Count == 0) if (Reminders.Count == 0)
return null; return null;
return Reminders.Values[0]; return Reminders.Min;
} }
public Reminder GetById(int targetId) public Reminder GetById(int targetId) {
{ return Reminders.First((Reminder nextReminder) => nextReminder.Id == targetId);
return Reminders.First((KeyValuePair<DateTime, Reminder> nextPair) => nextPair.Value.Id == targetId).Value;
} }
public void DeleteReminder(Reminder nextReminder) public void DeleteReminder(Reminder nextReminder) {
{ if (!Reminders.Remove(nextReminder))
Reminders.Remove(nextReminder.Time); 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 // 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 // we receive a request for a new reminder
lock (saveLock) 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( XmlWriter xml = XmlWriter.Create(
filename, filename,
new XmlWriterSettings() { Indent = true } new XmlWriterSettings() { Indent = true }
@ -74,7 +72,7 @@ namespace RhinoReminds
xml.WriteElementString("NextId", nextId.ToString()); xml.WriteElementString("NextId", nextId.ToString());
xml.WriteStartElement("Reminders"); xml.WriteStartElement("Reminders");
foreach (Reminder nextReminder in Reminders.Values) foreach (Reminder nextReminder in Reminders)
nextReminder.WriteToXml(xml); nextReminder.WriteToXml(xml);
xml.WriteEndElement(); xml.WriteEndElement();
@ -89,15 +87,15 @@ namespace RhinoReminds
XmlDocument xml = new XmlDocument(); XmlDocument xml = new XmlDocument();
xml.Load(filepath); xml.Load(filepath);
ReminderList result = new ReminderList(); ReminderList result = new ReminderList() {
result.nextId = int.Parse(xml.GetElementsByTagName("NextId")[0].InnerText); nextId = int.Parse(xml.GetElementsByTagName("NextId")[0].InnerText)
foreach (XmlNode reminderXML in xml.GetElementsByTagName("Reminders")[0].ChildNodes) };
{
foreach (XmlNode reminderXML in xml.GetElementsByTagName("Reminders")[0].ChildNodes) {
Reminder nextReminder = Reminder.FromXml(reminderXML); Reminder nextReminder = Reminder.FromXml(reminderXML);
DateTime timeKey = nextReminder.Time; while (result.Reminders.Contains(nextReminder))
while (result.Reminders.ContainsKey(timeKey)) nextReminder.TweakTime();
timeKey = timeKey.AddMilliseconds(1); result.Reminders.Add(nextReminder);
result.Reminders.Add(timeKey, nextReminder);
} }
return result; return result;

View File

@ -43,19 +43,19 @@
</Reference> </Reference>
<Reference Include="System.Xml" /> <Reference Include="System.Xml" />
<Reference Include="Microsoft.Recognizers.Definitions"> <Reference Include="Microsoft.Recognizers.Definitions">
<HintPath>..\packages\Microsoft.Recognizers.Text.1.1.3\lib\net462\Microsoft.Recognizers.Definitions.dll</HintPath> <HintPath>..\packages\Microsoft.Recognizers.Text.1.1.4\lib\net462\Microsoft.Recognizers.Definitions.dll</HintPath>
</Reference> </Reference>
<Reference Include="Microsoft.Recognizers.Text"> <Reference Include="Microsoft.Recognizers.Text">
<HintPath>..\packages\Microsoft.Recognizers.Text.1.1.3\lib\net462\Microsoft.Recognizers.Text.dll</HintPath> <HintPath>..\packages\Microsoft.Recognizers.Text.1.1.4\lib\net462\Microsoft.Recognizers.Text.dll</HintPath>
</Reference> </Reference>
<Reference Include="Microsoft.Recognizers.Text.Number"> <Reference Include="Microsoft.Recognizers.Text.Number">
<HintPath>..\packages\Microsoft.Recognizers.Text.Number.1.1.3\lib\net462\Microsoft.Recognizers.Text.Number.dll</HintPath> <HintPath>..\packages\Microsoft.Recognizers.Text.Number.1.1.4\lib\net462\Microsoft.Recognizers.Text.Number.dll</HintPath>
</Reference> </Reference>
<Reference Include="Microsoft.Recognizers.Text.NumberWithUnit"> <Reference Include="Microsoft.Recognizers.Text.NumberWithUnit">
<HintPath>..\packages\Microsoft.Recognizers.Text.NumberWithUnit.1.1.3\lib\net462\Microsoft.Recognizers.Text.NumberWithUnit.dll</HintPath> <HintPath>..\packages\Microsoft.Recognizers.Text.NumberWithUnit.1.1.4\lib\net462\Microsoft.Recognizers.Text.NumberWithUnit.dll</HintPath>
</Reference> </Reference>
<Reference Include="Microsoft.Recognizers.Text.DateTime"> <Reference Include="Microsoft.Recognizers.Text.DateTime">
<HintPath>..\packages\Microsoft.Recognizers.Text.DateTime.1.1.3\lib\net462\Microsoft.Recognizers.Text.DateTime.dll</HintPath> <HintPath>..\packages\Microsoft.Recognizers.Text.DateTime.1.1.4\lib\net462\Microsoft.Recognizers.Text.DateTime.dll</HintPath>
</Reference> </Reference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -68,6 +68,8 @@
<Compile Include="Exceptions.cs" /> <Compile Include="Exceptions.cs" />
<Compile Include="Utilities\Range.cs" /> <Compile Include="Utilities\Range.cs" />
<Compile Include="Utilities\TextHelpers.cs" /> <Compile Include="Utilities\TextHelpers.cs" />
<Compile Include="SimpleXmppClient.cs" />
<Compile Include="CompareReminders.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="packages.config" /> <None Include="packages.config" />

View File

@ -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)
{
}
/// <summary>
/// Sends a chat message to the specified JID.
/// </summary>
/// <param name="to">The JID to send the message to.</param>
/// <param name="message">The messaage to send.</param>
public void SendChatMessage(Jid to, string message)
{
//Console.WriteLine($"[Rhino/Send/Chat] Sending {message} -> {to}");
SendMessage(
to, message,
null, null, MessageType.Chat
);
}
/// <summary>
/// Sends a chat message in direct reply to a given incoming message.
/// </summary>
/// <param name="originalMessage">Original message.</param>
/// <param name="reply">Reply.</param>
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
);
}
}
}

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<packages> <packages>
<package id="Microsoft.Recognizers.Text" version="1.1.3" targetFramework="net47" /> <package id="Microsoft.Recognizers.Text" version="1.1.4" targetFramework="net47" />
<package id="Microsoft.Recognizers.Text.DateTime" version="1.1.3" targetFramework="net47" /> <package id="Microsoft.Recognizers.Text.DateTime" version="1.1.4" targetFramework="net47" />
<package id="Microsoft.Recognizers.Text.Number" version="1.1.3" targetFramework="net47" /> <package id="Microsoft.Recognizers.Text.Number" version="1.1.4" targetFramework="net47" />
<package id="Microsoft.Recognizers.Text.NumberWithUnit" version="1.1.3" targetFramework="net47" /> <package id="Microsoft.Recognizers.Text.NumberWithUnit" version="1.1.4" targetFramework="net47" />
<package id="S22.Xmpp" version="1.0.0.0" targetFramework="net47" /> <package id="S22.Xmpp" version="1.0.0.0" targetFramework="net47" />
<package id="System.Collections.Immutable" version="1.5.0" targetFramework="net47" /> <package id="System.Collections.Immutable" version="1.5.0" targetFramework="net47" />
<package id="System.ValueTuple" version="4.5.0" targetFramework="net47" /> <package id="System.ValueTuple" version="4.5.0" targetFramework="net47" />

View File

@ -2,15 +2,53 @@
Description=RhinoReminds XMPP Bot Description=RhinoReminds XMPP Bot
After=network.target prosody.service After=network.target prosody.service
# No more than 5 crashes in 12 hours
StartLimitIntervalSec=43200
StartLimitBurst=5
[Service] [Service]
Type=simple Type=forking
# Another Type option: forking PIDFile=/run/rhinoreminds/rhinoreminds.pid
User=rhinoreminds # We change our own user
WorkingDirectory=/srv/rhinoreminds User=root
ExecStart=/srv/rhinoreminds/start_service.sh WorkingDirectory=/srv/kraggwapple
ExecStart=/srv/kraggwapple/start_service.sh
Restart=on-failure Restart=on-failure
# Other Restart options: or always, on-abort, etc # 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` # 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 #StandardOutput=syslog
@ -19,3 +57,8 @@ Restart=on-failure
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@ -1,8 +1,15 @@
#!/usr/bin/env bash #!/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_JID;
export XMPP_PASSWORD; 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'