Compare commits

...

62 commits
v0.1 ... master

Author SHA1 Message Date
c13dd77b5f
Empty commit to test build system
All checks were successful
continuous-integration/laminar-elessar Build 40 succeeded in 1 minute 8 seconds .
2019-05-08 15:59:55 +01:00
3b072f3e97
Bugfix: Don't say we're late if we aren't 2019-05-08 15:44:46 +01:00
3d4af3eb2f
Add 1s grace time for processing to sorry I'm late message
All checks were successful
continuous-integration/laminar-elessar Build 39 succeeded in 1 minute 14 seconds .
2019-03-31 12:14:15 +01:00
ef8c90b4ad
Remove debug call in CI script
All checks were successful
continuous-integration/laminar-elessar Build 38 succeeded in 1 minute 37 seconds .
2019-03-29 00:08:09 +00:00
b47c7e4cf7
Properly detect branch name using the git-repo environment variable
All checks were successful
continuous-integration/laminar-elessar Build 37 succeeded in 1 minute 31 seconds .
2019-03-29 00:05:05 +00:00
23be94ff5e
Add debug logging to ci script
All checks were successful
continuous-integration/laminar-elessar Build 36 succeeded in 47 seconds .
2019-03-28 23:07:48 +00:00
eeea59eaff
Add --auto-exit CLI param
All checks were successful
continuous-integration/laminar-elessar Build 35 succeeded in 47 seconds .
2019-03-28 23:04:52 +00:00
dced63f02c
Don't deploy if we're on master
All checks were successful
continuous-integration/laminar-elessar Build 34 succeeded in 52 seconds .
2019-03-28 23:00:05 +00:00
c9d2f1b939
Turns out I didn't understand how SortedSet IComparers work :P
All checks were successful
continuous-integration/laminar-elessar Build 33 succeeded in 1 minute 3 seconds .
2019-03-28 20:35:28 +00:00
141bccc03c
Embed git commit hash in binary & output version number
All checks were successful
continuous-integration/laminar-elessar Build 32 succeeded in 1 minute 1 second .
2019-03-28 20:03:10 +00:00
ab19b82d35
Update NuGet packages
All checks were successful
continuous-integration/laminar-elessar Build 31 succeeded in 1 minute 2 seconds .
2019-03-28 19:34:49 +00:00
48c62f53e1
Update lantern build engine
All checks were successful
continuous-integration/laminar-elessar Build 30 succeeded in 1 minute 2 seconds .
2019-03-28 19:30:05 +00:00
23fb6cf820
Bugfix: Actually get the really right reminder this time....hopefully
All checks were successful
continuous-integration/laminar-elessar Build 29 succeeded in 1 minute 4 seconds .
Also add continuous deployment! :D :D :D
2019-03-28 19:27:54 +00:00
b7f3f27042
Add logrotate script
All checks were successful
continuous-integration/laminar-elessar Build 28 succeeded in 53 seconds .
2019-03-14 22:31:02 +00:00
64bb9550b3
Bugfix: Wait in the right reminder next, fix regexes
All checks were successful
continuous-integration/laminar-elessar Build 77 succeeded in 46 seconds .
2019-03-11 21:21:18 +00:00
23a9fa3ed7
Change it back, because it's not working right.
All checks were successful
continuous-integration/laminar-elessar Build 76 succeeded in 44 seconds .
2019-03-11 20:51:29 +00:00
49316836d9
Merge branch 'master' of git.starbeamrainbowlabs.com:sbrl/RhinoReminds
All checks were successful
continuous-integration/laminar-elessar Build 75 succeeded in 55 seconds .
2019-03-11 20:41:54 +00:00
a256158f89
Tweak Task.Delay() to fix odd issue 2019-03-11 20:41:49 +00:00
e60dd12751
Bugfix: Don't crash if an avatar isn't requested
All checks were successful
continuous-integration/laminar-elessar Build 66 succeeded in 51 seconds .
2019-03-04 14:40:12 +00:00
8099155455
Change compilation target to AnyCPU
All checks were successful
continuous-integration/laminar-elessar Build 65 succeeded in 51 seconds .
2019-03-03 12:37:35 +00:00
b32999f80e
Update Microsoft.Recognisers.Text
All checks were successful
continuous-integration/laminar-elessar Build 64 succeeded in 1 minute 13 seconds .
2019-03-03 12:23:48 +00:00
c3908ec2c7
Start writing package build task, but it's not finished yet
All checks were successful
continuous-integration/laminar-elessar Build 63 succeeded in 48 seconds .
2019-02-17 23:35:06 +00:00
39420d70d5
Empty commit to test build system
All checks were successful
continuous-integration/laminar-elessar Build 62 succeeded in 41 seconds .
2019-02-17 16:24:27 +00:00
44c809eb75
Empty commit to test build system 2019-02-17 16:08:02 +00:00
9b9dae00f8
Empty commit to test build system 2019-02-17 16:08:00 +00:00
29c307e297
Empty commit to test build system 2019-02-17 16:07:59 +00:00
6ec324ef7c
Empty commit to test build system 2019-02-17 16:04:42 +00:00
9343ce875c
Add link to associated blog post to README.md 2019-02-17 15:58:18 +00:00
e23b2e0f3c
Update lantern build engine again 2019-02-12 20:34:37 +00:00
efffaa7465
Update lantern build engine 2019-02-12 20:30:58 +00:00
de1cc6bb51
Run debug & release builds in parallel 2019-02-12 20:22:28 +00:00
83c1246fab
Actually build for release mya 2019-02-12 20:08:27 +00:00
fd74e5f3a7
Oops 2019-02-12 20:01:15 +00:00
7a5a43f9a2
Add more debugging statements to try & figure out what's going wrong 2019-02-12 19:59:57 +00:00
22c2212952
[CI] Where _are_ we? 2019-02-12 19:51:59 +00:00
950d12f08b
Hrm. The release binary archive does't appear to be generating correctly.
Will this fix it?
2019-02-12 19:50:16 +00:00
48cc42ef2a
CI: Create archives after build automatically 2019-02-12 19:44:37 +00:00
1360eb9c6f
[build] Add ability to create archives automagically 2019-02-12 19:43:40 +00:00
1f88789309
Update lantern build engine 2019-02-12 19:30:13 +00:00
09669d9acf
Document assorted files that are useful for running RhinoReminds as a system service 2019-02-12 19:28:31 +00:00
a4d996fb3d
Bugfix: Fix crash if the next reminder is deleted before it can be sent.
Also enable some debug logging, just in case it acts up again.
2019-02-08 11:30:16 +00:00
b5deb649dc
Display message on startup to aid clarity in logs 2019-02-08 11:02:52 +00:00
6d400b7c3a
[ci] Let's try making the auto-checkout test stricter 2019-02-08 00:40:04 +00:00
46f55a086b
Is the auto-checkout working? 2019-02-08 00:38:50 +00:00
20f5329b9c
Empty commit to test build system 2019-02-08 00:36:42 +00:00
7ae561a050
Empty commit to test build system 2019-02-08 00:31:31 +00:00
99101cd519
[ci] Display version of mono 2019-02-08 00:27:49 +00:00
fef5dcbc79
Merge branch 'master' of git.starbeamrainbowlabs.com:sbrl/RhinoReminds 2019-02-08 00:25:24 +00:00
c5e54864bc
Formatting in service file 2019-02-08 00:24:45 +00:00
6f9ff7eef0
Merge branch 'master' of git.starbeamrainbowlabs.com:sbrl/RhinoReminds 2019-02-04 13:54:34 +00:00
517e3dcafc
Update systemd config to optionally use rsyslog 2019-02-04 13:54:21 +00:00
97a6d95902
start_service.sh: Apparently sudo doesn't quit - and stays as a wrapper process.Solve this by disowning after sudo & avoid showing the password in the sudo process arguments by preserving the environment when sudoing. 2019-02-01 22:26:22 +00:00
1314b3ab75
Update service files etc. 2018-12-27 14:05:40 +00:00
7cbf1ef79c
Generate error on unknown argument 2018-12-27 13:38:23 +00:00
430b5082dd
Add --pidfile support, and output error properly if XMPP_JID or XMPP_PASSWORD aren't set 2018-12-27 13:36:55 +00:00
579078edf0
Change the internal backing list to a SortedSet to reduce complexity & increase stability 2018-12-27 12:29:41 +00:00
adfb482072
Bugfix: Notify the reminder thread when a reminder gets deleted from the list 2018-12-22 16:05:45 +00:00
4174ec85c6
Fix the reconnection logic. Hooray! 2018-12-21 10:58:14 +00:00
505e500634
Bugfix: COrrect find-and-replace ordering.
More work may be needed here.... :-"
'
2018-12-19 17:46:53 +00:00
b5df7ab94e
README: Layout 2018-12-06 00:21:43 +00:00
dd96754126
Fill out more of the README 2018-12-06 00:20:58 +00:00
e58f56b06d
README: Add link to releases page 2018-12-06 00:14:36 +00:00
18 changed files with 968 additions and 197 deletions

3
.gitignore vendored
View file

@ -1,4 +1,7 @@
git-hash.txt
*.tar.gz
*.backup
# Created by https://www.gitignore.io/api/monodevelop,visualstudio,csharp,git
# Edit at https://www.gitignore.io/?templates=monodevelop,visualstudio,csharp,git

View file

@ -2,7 +2,15 @@
> An XMPP reminder bot written in C#.
I've blogged about this project here: [RhinoReminds: An XMPP reminder bot for my convenience](https://starbeamrainbowlabs.com/blog/article.php?article=posts/328-RhinoReminds.html)
## 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 +40,14 @@ mono [--debug] RhinoReminds.exe [--help]
RhinoReminds.exe [--help]
```
### Setting up RhinoReminds as a system service
Some helpful template files are located in this repository to aid in setting _RhinoReminds_ up as a system service.
- [`start_service.sh`](https://git.starbeamrainbowlabs.com/sbrl/RhinoReminds/src/branch/master/start_service.sh) Contains a script that reads in CLI variables from a file, sets up a PID file directory with the appropriate permissions, and then executes _RhinoReminds_ as an unprivileged user.
- [`rhinoreminds.service`](https://git.starbeamrainbowlabs.com/sbrl/RhinoReminds/src/branch/master/rhinoreminds.service) Contains a systemd service file compatible with `start_service.sh`
- [`rhinoreminds-rsyslog.conf`](https://git.starbeamrainbowlabs.com/sbrl/RhinoReminds/src/branch/master/rhinoreminds-rsyslog.conf) Contains an Rsyslog definition file compatible with the systemd service file defined above. When put in `/etc/rsyslog.d` (don't forget to restart the `rsyslog` service!), it will write and auto-rotate log files of the standard output and standard error of the main _RhinoReminds_ process to a subfolder fo `/var/log` automatically.
## 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.
@ -61,6 +77,17 @@ 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)
- [S22.Xmpp API Documentation](https://smiley22.github.io/S22.Xmpp/Documentation/)

View file

@ -9,7 +9,6 @@ using System.Threading;
using System.Threading.Tasks;
using RhinoReminds.Utilities;
using S22.Xmpp;
using S22.Xmpp.Client;
using S22.Xmpp.Im;
using SBRL.Geometry;
@ -24,35 +23,35 @@ namespace RhinoReminds
public event OnConnectedHandler OnConnected;
public readonly string Jid;
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;
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 XmppClient client;
private SimpleXmppClient client;
/// <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.
/// </summary>
private int nextBackoffDelay = 1;
private int defaultBackoffDelay = 1;
private float backoffDelayMultiplier = 2;
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 int giveUpTimeout = 30;
private readonly int giveUpTimeout = 30;
public ClientListener(string inJid, string inPassword)
{
Jid = inJid;
Jid = new Jid(inJid);
password = inPassword;
}
@ -64,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<bool> 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)
{
@ -94,17 +115,25 @@ namespace RhinoReminds
}
Console.WriteLine($"[Rhino/Setup] Connected as {Jid}");
OnConnected(this, new OnConnectedEventArgs());
OnConnected?.Invoke(this, new OnConnectedEventArgs());
return true;
}
private void disconnect()
{
client.Close();
client.Dispose();
client = null;
Console.WriteLine($"[Rhino] Disconnected from server.");
}
#region XMPP Event Handling
private bool subscriptionRequestHandler(Jid from)
{
if (!AllowedDomains.Contains("*") && !AllowedDomains.Contains(from.Domain)) {
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}");
@ -115,17 +144,8 @@ namespace RhinoReminds
{
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;
if(!client.Connected || e.Exception is IOException) {
reconnect().Wait();
}
}
@ -140,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) ");
}
}
@ -160,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;
}
@ -172,15 +192,20 @@ 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, "**Version:** Show the program version I am currently running");
client.SendChatReply(message, "\nExample: 'Remind me to feed the cat tomorrow at 6pm'");
break;
case "version":
client.SendChatReply(message, $"I'm currently running {Program.Version}.");
break;
case "delete":
@ -201,19 +226,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<Reminder> userReminderList = reminderList.Reminders.Values.Where(
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");
@ -222,7 +249,7 @@ namespace RhinoReminds
}
listMessage.AppendLine();
listMessage.AppendLine($"({userReminderList.Count()} total)");
sendChatReply(message, listMessage.ToString());
client.SendChatReply(message, listMessage.ToString());
break;
@ -231,8 +258,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(
@ -245,136 +272,144 @@ namespace RhinoReminds
@"^remind\s+(?:me\s+)?",
@"^me\s+",
@"^on\s+",
@"my",
@"you",
@"your",
@"\byou\b",
@"\byour\b",
@"\bmy\b",
@"&" // 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
/// <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
#region Reminder Listening
private void interruptReminderWatcher()
{
CancellationTokenSource oldToken = reminderWatcherReset;
reminderWatcherReset = new CancellationTokenSource();
oldToken?.Cancel();
}
private async Task watchForReminders()
{
CancellationTokenSource cancellationSource = new CancellationTokenSource();
CancellationToken cancellationToken = cancellationSource.Token;
interruptReminderWatcher();
Reminder nextReminder = reminderList.GetNextReminder();
reminderList.OnReminderListUpdate += (object sender, Reminder newReminder) => {
// ----- Events -----
// This will run on the firing thread, not on this thread
reminderList.OnReminderListUpdate += (object sender, ReminderListUpdateEventArgs eventArgs) => {
Reminder newNextReminder = reminderList.GetNextReminder();
//Console.WriteLine("[Rhino/Reminderd/Canceller] Reminder added - comparing.");
//Console.WriteLine($"[Rhino/Reminderd/Canceller] {nextReminder} / {newNextReminder}");
Console.WriteLine("[Rhino/Reminderd/Canceller] Reminder added - comparing.");
Console.WriteLine($"[Rhino/Reminderd/Canceller] {nextReminder} / {newNextReminder}");
if (nextReminder != newNextReminder) {
//Console.WriteLine($"[Rhino/Reminderd/Canceller] Cancelling");
Console.WriteLine($"[Rhino/Reminderd/Canceller] Cancelling");
nextReminder = newNextReminder;
cancellationSource.Cancel();
interruptReminderWatcher();
}
};
// ------------------
while (true)
{
TimeSpan nextWaitingTime;
while (true) {
nextReminder = reminderList.GetNextReminder();
// Wait for the next reminder
TimeSpan nextWaitingTime = new TimeSpan();
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);
if (DateTime.Now < nextReminder.Time)
{
Console.WriteLine($"[Rhino/Reminderd] Next reminder: {nextReminder}");
Console.WriteLine($"[Rhino/Reminderd] Sleeping for {nextWaitingTime}");
int nextWaitingTimeMs = (int)nextWaitingTime.TotalMilliseconds;
if (nextWaitingTimeMs < 0) // if it overflows, sort it out
nextWaitingTimeMs = int.MaxValue;
await Task.Delay(nextWaitingTimeMs, reminderWatcherResetToken);
}
} else {
//Console.WriteLine("[Rhino/Reminderd] Sleeping until interrupted");
await Task.Delay(Timeout.Infinite, cancellationToken);
}
} catch (TaskCanceledException) {
//Console.WriteLine("[Rhino/Reminderd] Sleep interrupted, recalculating");
cancellationSource = new CancellationTokenSource();
cancellationToken = cancellationSource.Token;
else {
Console.WriteLine("[Rhino/Reminderd] Sleeping until interrupted");
await Task.Delay(Timeout.Infinite, reminderWatcherResetToken);
}
}
catch (TaskCanceledException)
{
Console.WriteLine("[Rhino/Reminderd] Sleep interrupted, recalculating");
continue;
}
if (cancellationToken.IsCancellationRequested) {
if (reminderWatcherResetToken.IsCancellationRequested) {
Console.WriteLine("[Rhino/Reminderd] Sleep interrupted, recalculating (but no exception thrown)");
cancellationSource = new CancellationTokenSource();
cancellationToken = cancellationSource.Token;
continue;
}
if (nextReminder.Time > DateTime.Now.AddSeconds(-1)) {
Console.WriteLine("[Rhino/Reminderd] Didn't sleep for long enough, going back to bed *yawn*");
continue;
}
Console.WriteLine($"[Rhino/Reminderd] Sending notification {nextReminder}");
Jid targetJid = nextReminder.Jid; // Apparently nextReminder is null after the sendAndDeleteReminder() call - very odd!
sendAndDeleteReminder(nextReminder);
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,
if (nextWaitingTime.TotalMilliseconds < -1 * 2000) {
client.SendChatMessage(
targetJid,
"(Sorry I'm late reminding you! I might not have been running at the time, " +
"or you might have scheduled a reminder for the past)"
);
}
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
}
}

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 x.Time.CompareTo(y.Time);
}
}
}

View file

@ -1,10 +1,14 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using RhinoReminds.Utilities;
using S22.Xmpp;
using S22.Xmpp.Client;
using S22.Xmpp.Im;
using SBRL.Utilities;
namespace RhinoReminds
{
@ -15,12 +19,21 @@ namespace RhinoReminds
public string Jid = null;
public string AvatarFilepath = string.Empty;
public string PidFile = null;
public bool ExitOnModify = false;
public string Password = null;
}
public static class Program
{
public static string Version {
get {
return $"v{Assembly.GetExecutingAssembly().GetName().Version}" +
$"-{EmbeddedFiles.ReadAllText("RhinoReminds.git-hash.txt").Substring(0, 7)}";
}
}
private static ProgramSettings settings = new ProgramSettings();
public static int Main(string[] args)
@ -36,7 +49,7 @@ namespace RhinoReminds
switch (args[i]) {
case "-h":
case "--help":
Console.WriteLine("--- RhinoReminds ---");
Console.WriteLine($"--- RhinoReminds {Version} ---");
Console.WriteLine("> An XMPP reminder bot");
Console.WriteLine(" By Starbeamrainbowlabs");
Console.WriteLine();
@ -44,10 +57,12 @@ namespace RhinoReminds
Console.WriteLine(" mono RhinoReminds.exe {options}");
Console.WriteLine();
Console.WriteLine("Options:");
Console.WriteLine(" -h --help Show this message");
Console.WriteLine($" -f --file Specify where to save reminders (default: {settings.Filepath})");
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(" --auto-exit Watch for changes to the executable on disk and automatically quit with an exit code of 0 if a modification is detected. Useful for integration with a service manager and continuous deployment.");
Console.WriteLine();
Console.WriteLine("Environment Variables:");
Console.WriteLine(" XMPP_JID The JID to login to");
@ -67,33 +82,96 @@ namespace RhinoReminds
case "--avatar":
settings.AvatarFilepath = args[++i];
break;
case "--pidfile":
settings.PidFile = args[++i];
break;
case "--auto-exit":
settings.ExitOnModify = true;
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()
{
Console.WriteLine("************************************");
Console.WriteLine("***** RhinoReminds is starting *****");
Console.WriteLine("************************************");
Console.WriteLine($"[Program] Running {Version}");
if (settings.ExitOnModify) {
ExitWatcher.EnableExitOnModify();
}
ClientListener client = new ClientListener(settings.Jid, settings.Password) {
ReminderFilePath = settings.Filepath
};
client.AllowedDomains.Add(settings.AllowedDomain);
// Update the avatar if appropriate
if (settings.AvatarFilepath != string.Empty) {
client.OnConnected += (object sender, OnConnectedEventArgs eventArgs) => {
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
client.Start().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.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<Reminder>
{
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<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)
{
@ -66,7 +97,8 @@ namespace RhinoReminds
return new Reminder(id, jid, dateTime, message);
}
#endregion
}
}

View file

@ -1,20 +1,20 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using S22.Xmpp;
namespace RhinoReminds
{
public delegate void OnReminderListUpdateHandler(object sender, Reminder newReminder);
public class ReminderListUpdateEventArgs : EventArgs { }
public delegate void OnReminderListUpdateHandler(object sender, ReminderListUpdateEventArgs newReminder);
public class ReminderList
{
private int nextId = 0;
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;
@ -29,29 +29,30 @@ 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);
OnReminderListUpdate(this, result);
while (Reminders.Contains(result))
result.TweakTime();
Reminders.Add(result);
OnReminderListUpdate(this, new ReminderListUpdateEventArgs());
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<DateTime, Reminder> 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!");
OnReminderListUpdate(this, new ReminderListUpdateEventArgs());
}
@ -62,7 +63,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 +75,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,20 +90,28 @@ 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;
}
#endregion
public override string ToString()
{
string result = "Reminder list:";
foreach (Reminder nextReminder in Reminders)
Console.WriteLine($" - {nextReminder}");
return result;
}
}
}

View file

@ -19,7 +19,6 @@
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<ExternalConsole>true</ExternalConsole>
<PlatformTarget>x86</PlatformTarget>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' ">
<Optimize>true</Optimize>
@ -27,7 +26,6 @@
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<ExternalConsole>true</ExternalConsole>
<PlatformTarget>x86</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
@ -43,19 +41,19 @@
</Reference>
<Reference Include="System.Xml" />
<Reference Include="Microsoft.Recognizers.Definitions">
<HintPath>..\packages\Microsoft.Recognizers.Text.1.1.4\lib\net462\Microsoft.Recognizers.Definitions.dll</HintPath>
<HintPath>..\packages\Microsoft.Recognizers.Text.1.1.6\lib\net462\Microsoft.Recognizers.Definitions.dll</HintPath>
</Reference>
<Reference Include="Microsoft.Recognizers.Text">
<HintPath>..\packages\Microsoft.Recognizers.Text.1.1.4\lib\net462\Microsoft.Recognizers.Text.dll</HintPath>
<HintPath>..\packages\Microsoft.Recognizers.Text.1.1.6\lib\net462\Microsoft.Recognizers.Text.dll</HintPath>
</Reference>
<Reference Include="Microsoft.Recognizers.Text.Number">
<HintPath>..\packages\Microsoft.Recognizers.Text.Number.1.1.4\lib\net462\Microsoft.Recognizers.Text.Number.dll</HintPath>
<HintPath>..\packages\Microsoft.Recognizers.Text.Number.1.1.6\lib\net462\Microsoft.Recognizers.Text.Number.dll</HintPath>
</Reference>
<Reference Include="Microsoft.Recognizers.Text.NumberWithUnit">
<HintPath>..\packages\Microsoft.Recognizers.Text.NumberWithUnit.1.1.4\lib\net462\Microsoft.Recognizers.Text.NumberWithUnit.dll</HintPath>
<HintPath>..\packages\Microsoft.Recognizers.Text.NumberWithUnit.1.1.6\lib\net462\Microsoft.Recognizers.Text.NumberWithUnit.dll</HintPath>
</Reference>
<Reference Include="Microsoft.Recognizers.Text.DateTime">
<HintPath>..\packages\Microsoft.Recognizers.Text.DateTime.1.1.4\lib\net462\Microsoft.Recognizers.Text.DateTime.dll</HintPath>
<HintPath>..\packages\Microsoft.Recognizers.Text.DateTime.1.1.6\lib\net462\Microsoft.Recognizers.Text.DateTime.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
@ -68,6 +66,10 @@
<Compile Include="Exceptions.cs" />
<Compile Include="Utilities\Range.cs" />
<Compile Include="Utilities\TextHelpers.cs" />
<Compile Include="SimpleXmppClient.cs" />
<Compile Include="CompareReminders.cs" />
<Compile Include="Utilities\EmbeddedFiles.cs" />
<Compile Include="Utilities\ExitWatcher.cs" />
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
@ -76,4 +78,11 @@
<Folder Include="Utilities\" />
</ItemGroup>
<Import Project="$(MSBuildBinPath)\Microsoft.CSharp.targets" />
<Target Name="BeforeBuild" BeforeTargets="Build">
<Exec Command="git rev-parse HEAD &gt;git-hash.txt" WorkingDirectory="$(ProjectDir)" IgnoreExitCode="true" />
</Target>
<ItemGroup>
<EmbeddedResource Include="git-hash.txt" />
</ItemGroup>
</Project>

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

@ -0,0 +1,287 @@
using System;
using System.Reflection;
using System.IO;
using System.Threading.Tasks;
using System.Collections.Generic;