406 lines
13 KiB
C#
406 lines
13 KiB
C#
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<string> AllowedDomains = new List<string>();
|
|
|
|
public string ReminderFilePath { get; set; } = "./reminders.xml";
|
|
private ReminderList reminderList = new ReminderList();
|
|
private CancellationTokenSource reminderWatcherReset;
|
|
private CancellationToken reminderWatcherResetToken => reminderWatcherReset.Token;
|
|
|
|
private SimpleXmppClient client;
|
|
/// <summary>
|
|
/// The initial number of seconds to wait before trying to reconnect to the
|
|
/// server again if we loose our connection again.
|
|
/// </summary>
|
|
private readonly int defaultBackoffDelay = 1;
|
|
private readonly float backoffDelayMultiplier = 2;
|
|
/// <summary>
|
|
/// If a connection attempt doesn't succeed in this number of seconds,
|
|
/// give up and try again later.
|
|
/// </summary>
|
|
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<bool> 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(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, "\nExample: 'Remind me to feed the cat tomorrow at 6pm'");
|
|
break;
|
|
|
|
case "delete":
|
|
case "remove":
|
|
List<int> failed = new List<int>(),
|
|
succeeded = new List<int>();
|
|
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<Reminder> 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+",
|
|
@"you",
|
|
@"your",
|
|
@"my",
|
|
@"&" // 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()
|
|
{
|
|
reminderWatcherReset?.Cancel();
|
|
reminderWatcherReset = new CancellationTokenSource();
|
|
}
|
|
|
|
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, 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;
|
|
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] Sleeping for {nextWaitingTime}");
|
|
await Task.Delay(nextWaitingTime, reminderWatcherResetToken);
|
|
}
|
|
}
|
|
else {
|
|
//Console.WriteLine("[Rhino/Reminderd] Sleeping until interrupted");
|
|
await Task.Delay(Timeout.Infinite, reminderWatcherResetToken);
|
|
}
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
//Console.WriteLine("[Rhino/Reminderd] Sleep interrupted, recalculating");
|
|
interruptReminderWatcher();
|
|
continue;
|
|
}
|
|
|
|
if (reminderWatcherResetToken.IsCancellationRequested)
|
|
{
|
|
Console.WriteLine("[Rhino/Reminderd] Sleep interrupted, recalculating (but no exception thrown)");
|
|
interruptReminderWatcher();
|
|
continue;
|
|
}
|
|
|
|
Console.WriteLine($"[Rhino/Reminderd] Sending notification {nextReminder}");
|
|
sendAndDeleteReminder(nextReminder);
|
|
|
|
if (nextWaitingTime.TotalMilliseconds < 0) {
|
|
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)"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|