Write an XMPP bot in half an hour
Recently I've looked at using AI to extract key information from natural language, and creating a system service with systemd. The final piece of the puzzle is to write the bot itself - and that's what I'm posting about today.
Since not only do I use XMPP for instant messaging already but it's an open federated standard, I'll be building my bot on top of it for maximum flexibility.
To talk over XMPP programmatically, we're going to need library. Thankfully, I've located just such a library which appears to work well enough, called S22.XMPP. Especially nice is the comprehensive documentation that makes development go much more smoothly.
With our library in hand, let's begin! Our first order of business is to get some scaffolding in place to parse out the environment variables we'll need to login to an XMPP account.
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using S22.Xmpp;
using S22.Xmpp.Client;
using S22.Xmpp.Im;
namespace XmppBotDemo
{
public static class MainClass
{
// Needed later
private static XmppClient client;
// Settings
private static Jid ourJid = null;
private static string password = null;
public static int Main(string[] args)
{
// Read in the environment variables
ourJid = new Jid(Environment.GetEnvironmentVariable("XMPP_JID"));
password = Environment.GetEnvironmentVariable("XMPP_PASSWORD");
// Ensure they are present
if (ourJid == null || password == null) {
Console.Error.WriteLine("XMPP Bot Demo");
Console.Error.WriteLine("=============");
Console.Error.WriteLine("");
Console.Error.WriteLine("Usage:");
Console.Error.WriteLine(" ./XmppBotDemo.exe");
Console.Error.WriteLine("");
Console.Error.WriteLine("Environment Variables:");
Console.Error.WriteLine(" XMPP_JID Required. Specifies the JID to login with.");
Console.Error.WriteLine(" XMPP_PASSWORD Required. Specifies the password to login with.");
return 1;
}
// TODO: Connect here
return 0;
}
}
}
Excellent! We're reading in & parsing 2 environment variables: XMPP_JID
(the username), and XMPP_PASSWORD
. It's worth noting that you can call these environment variables anything you like! I chose those names as they describe their contents well. It's also worth mentioning that it's important to use environment variables for secrets passing them as command-line arguments cases them to be much more visible to other uses of the system!
Let's connect to the XMPP server with our newly read-in credentials:
// Create the client instance
client = new XmppClient(ourJid.Domain, ourJid.Node, password);
client.Error += errorHandler;
client.SubscriptionRequest += subscriptionRequestHandler;
client.Message += messageHandler;
client.Connect();
// Wait for a connection
while (!client.Connected)
Thread.Sleep(100);
Console.WriteLine($"[Main] Connected as {ourJid}.");
// Wait forever.
Thread.Sleep(Timeout.Infinite);
// TODO: Automatically reconnect to the server when we get disconnected.
Cool! Here, we create a new instance of the XMPPClient
class, and attach 3 event handlers, which we'll look at later. We then connect to the server, and then wait until it completes - and then write a message to the console. It looks like S22.Xmpp
spins up a new thread, so unfortunately we can't catch any errors it throws with a traditional try-catch statement. Instead, we'll have to ensure we're really careful that we catch any exceptions we throw accidentally - otherwise we'll get disconnected!
It does appear that XmppClient
catches some errors though, which trigger the Error
event - so we should attach an event handler to that.
/// <summary>
/// Handles any errors thrown by the XMPP client engine.
/// </summary>
private static void errorHandler(object sender, ErrorEventArgs eventArgs) {
Console.Error.WriteLine($"Error: {eventArgs.Reason}");
Console.Error.WriteLine(eventArgs.Exception);
}
Before a remote contact is able to talk to our bot, they will send us a subscription request - which we'll need to either accept or reject. This is also done via an event handler. It's the SubscriptionRequest
one this time:
/// <summary>
/// Handles requests to talk to us.
/// </summary>
/// <remarks>
/// Only allow people to talk to us if they are on the same domain we are.
/// You probably don't want this for production, but for developmental purposes
/// it offers some measure of protection.
/// </remarks>
/// <param name="from">The JID of the remote user who wants to talk to us.</param>
/// <returns>Whether we're going to allow the requester to talk to us or not.</returns>
public static bool subscriptionRequestHandler(Jid from) {
Console.WriteLine($"[Handler/SubscriptionRequest] {from} is requesting access, I'm saying {(from.Domain == ourJid.Domain?"yes":"no")}");
return from.Domain == ourJid.Domain;
}
This simply allows anyone on our own domain to talk to us. For development purposes this will offer us some measure of protection, but for production you should probably implement a whitelisting or logging system here.
The other interesting thing we can do here is send a user a chat message to either welcome them to the server, or explain why we rejected their request. To do this, we need to write a pair of utility methods, as sending chat messages with S22.Xmpp
is somewhat over-complicated:
#region Message Senders
/// <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 static void sendChatMessage(Jid to, string message)
{
//Console.WriteLine($"[Bot/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 static void sendChatReply(Message originalMessage, string reply)
{
//Console.WriteLine($"[Bot/Send/Reply] Sending {reply} -> {originalMessage.From}");
client.SendMessage(
originalMessage.From, reply,
null, originalMessage.Thread, MessageType.Chat
);
}
#endregion
The difference between these 2 methods is that one sends a reply directly to a message that we've received (like a threaded reply), and the other simply sends a message directly to another contact.
Now that we've got all of our ducks in a row, we can write the bot itself! This is done via the Message
event handler. For this demo, we'll write a bot that echo any messages to it in reverse:
/// <summary>
/// Handles incoming messages.
/// </summary>
private static void messageHandler(object sender, MessageEventArgs eventArgs) {
Console.WriteLine($"[Bot/Handler/Message] {eventArgs.Message.Body.Length} chars from {eventArgs.Jid}");
char[] messageCharArray = eventArgs.Message.Body.ToCharArray();
Array.Reverse(messageCharArray);
sendChatReply(
eventArgs.Message,
new string(messageCharArray)
);
}
Excellent! That's our bot complete. The full program is at the bottom of this post.
Of course, this is a starting point - not an ending point! A number of issues with this demo stand out. There isn't a whitelist, and putting the whole program in a single file doesn't sound like a good idea. The XMPP logic should probably be refactored out into a separate file, in order to keep the input settings parsing separate from the bot itself.
Other issues that probably need addressing include better error handling and more - but fixing them all here would complicate the example rather.
Edit: The code is also available in a git repository if you'd like to clone it down and play around with it :-)
Found this interesting? Got a cool use for it? Still confused? Comment below!
Complete Program
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using S22.Xmpp;
using S22.Xmpp.Client;
using S22.Xmpp.Im;
namespace XmppBotDemo
{
public static class MainClass
{
private static XmppClient client;
private static Jid ourJid = null;
private static string password = null;
public static int Main(string[] args)
{
// Read in the environment variables
ourJid = new Jid(Environment.GetEnvironmentVariable("XMPP_JID"));
password = Environment.GetEnvironmentVariable("XMPP_PASSWORD");
// Ensure they are present
if (ourJid == null || password == null) {
Console.Error.WriteLine("XMPP Bot Demo");
Console.Error.WriteLine("=============");
Console.Error.WriteLine("");
Console.Error.WriteLine("Usage:");
Console.Error.WriteLine(" ./XmppBotDemo.exe");
Console.Error.WriteLine("");
Console.Error.WriteLine("Environment Variables:");
Console.Error.WriteLine(" XMPP_JID Required. Specifies the JID to login with.");
Console.Error.WriteLine(" XMPP_PASSWORD Required. Specifies the password to login with.");
return 1;
}
// Create the client instance
client = new XmppClient(ourJid.Domain, ourJid.Node, password);
client.Error += errorHandler;
client.SubscriptionRequest += subscriptionRequestHandler;
client.Message += messageHandler;
client.Connect();
// Wait for a connection
while (!client.Connected)
Thread.Sleep(100);
Console.WriteLine($"[Main] Connected as {ourJid}.");
// Wait forever.
Thread.Sleep(Timeout.Infinite);
// TODO: Automatically reconnect to the server when we get disconnected.
return 0;
}
#region Event Handlers
/// <summary>
/// Handles requests to talk to us.
/// </summary>
/// <remarks>
/// Only allow people to talk to us if they are on the same domain we are.
/// You probably don't want this for production, but for developmental purposes
/// it offers some measure of protection.
/// </remarks>
/// <param name="from">The JID of the remote user who wants to talk to us.</param>
/// <returns>Whether we're going to allow the requester to talk to us or not.</returns>
public static bool subscriptionRequestHandler(Jid from) {
Console.WriteLine($"[Handler/SubscriptionRequest] {from} is requesting access, I'm saying {(from.Domain == ourJid.Domain?"yes":"no")}");
return from.Domain == ourJid.Domain;
}
/// <summary>
/// Handles incoming messages.
/// </summary>
private static void messageHandler(object sender, MessageEventArgs eventArgs) {
Console.WriteLine($"[Handler/Message] {eventArgs.Message.Body.Length} chars from {eventArgs.Jid}");
char[] messageCharArray = eventArgs.Message.Body.ToCharArray();
Array.Reverse(messageCharArray);
sendChatReply(
eventArgs.Message,
new string(messageCharArray)
);
}
/// <summary>
/// Handles any errors thrown by the XMPP client engine.
/// </summary>
private static void errorHandler(object sender, ErrorEventArgs eventArgs) {
Console.Error.WriteLine($"Error: {eventArgs.Reason}");
Console.Error.WriteLine(eventArgs.Exception);
}
#endregion
#region Message Senders
/// <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 static 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 static 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
}
}