Bridging the gap between XMPP and shell scripts
In a previous post, I set up a semi-automated backup system for my Raspberry Pi using duplicity
, sendxmpp
, and an external drive. It's been working fabulously for a while now, but unfortunately the other week sendxmpp
suddenly stopped working with no obvious explanation. Given the long list of arguments I had to pass it:
sendxmpp --file "${xmpp_config_file}" --resource "${xmpp_resource}" --tls --chatroom "${xmpp_target_chatroom}" ...........
....and the fact that I've had to tweak said arguments on a number of occasions, I thought it was time to switch it out for something better suited to the task at hand.
Unfortunately, finding such a tool proved to be a challenge. I even asked on Reddit - but nobody had anything that fit the bill (xmpp-bridge
wouldn't compile correctly - and didn't support multi-user chatrooms anyway, and xmpppy
was broken too).
If you're unsure as to what XMPP is, I'd recommend checkout out either this or this tutorial. They both give a great introduction to what it is, what it does, and how it works - and the rest of this post will make much more sense if you read that first :-)
To this end, I finally gave in and wrote my own tool, which I've called xmppbridge. It's a global Node.JS script that uses the simple-xmpp
to forward the standard input to a given JID over XMPP - which can optionally be a group chat.
In this post, I'm going to look at how I put it together, some of the issues I ran into along the way, and how I solved them. If you're interested in how to install and use it, then the package page on npm will tell you everything you need to know:
xmppbridge
on npm
Architectural Overview
The script consists of 3 files:
index.sh
- Calls the main script with ES6 modules enabled
index.mjs
- Parses the command-line arguments and environment variables out, and provides a nice CLI
XmppBridge.mjs
- The bit that actually captures input from stdin and sends it via XMPP
Let's look at each of these in turn - starting with the command-line interface.
CLI Parsing
The CLI itself is relatively simple - and follows a paradigm I've used extensively in C♯ (although somewhat modified of course to get it to work in Node.JS, and without fancy ANSI colouring etc.).
#!/usr/bin/env node
"use strict";
import XmppBridge from './XmppBridge.mjs';
const settings = {
jid: process.env.XMPP_JID,
destination_jid: null,
is_destination_groupchat: false,
password: process.env.XMPP_PASSWORD
};
let extras = [];
// The first arg is the script name itself
for(let i = 1; i < process.argv.length; i++) {
if(!process.argv[i].startsWith("-")) {
extras.push(process.argv[i]);
continue;
}
switch(process.argv[i]) {
case "-h":
case "--help":
// ........
break;
// ........
default:
console.error(`Error: Unknown argument '${process.argv[i]}'.`);
process.exit(2);
break;
}
}
We start with a shebang, telling Linux-based systems to execute the script with Node.JS. Following that, we import the XmppBridge class that's located in XmppBrdige.mjs
(we'll come back to this later). Then, we define an object to hold our settings - and pull in the environment variables along with defining some defaults for other parameters.
With that setup, we can then parse the command-line arguments themselves - using the exact same paradigm I've used time and time again in C♯.
Once the command-line arguments are parsed, we validate the final settings to ensure that the user hasn't left any required parameters undefined:
for(let environment_varable of ["XMPP_JID", "XMPP_PASSWORD"]) {
if(typeof process.env[environment_varable] == "undefined") {
console.error(`Error: The environment variable ${environment_varable} wasn't found.`);
process.exit(1);
}
}
if(typeof settings.destination_jid != "string") {
console.error("Error: No destination jid specified.");
process.exit(5);
}
That's basically all that index.mjs
does. All that's really left is passing the parameters to an instance of XmppBridge
:
const bridge = new XmppBridge(
settings.destination_jid,
settings.is_destination_groupchat
);
bridge.start(settings.jid, settings.password);
Shebang Trouble
Because I've used ES6 modules here, currently Node must be informed of this via the --experimental-modules
CLI argument like this:
node --experimental-modules ./index.mjs
If we're going to make this a global command-line tool via the bin
directive in package.json
, then we're going to have to ensure that this flag gets passed to Node and not our program. While we could alter the shebang, that comes with the awkward problem that not all systems (in fact relatively few) support using both env
and passing arguments. For example, this:
#!/usr/bin/env node --experimental-modules
Wouldn't work, because env
doesn't recognise that --experimental-modules
is actually a command-line argument and not part of the binary name that it should search for. I did see some Linux systems support env -S
to enable this functionality, but it's hardly portable and doesn't even appear to work all the time anyway - so we'll have to look for another solution.
Another way we could do it is by dropping the env
entirely. We could do this:
#!/usr/local/bin/node --experimental-modules
...which would work fine on my system, but probably not on anyone else's if they haven't installed Node to the same place. Sadly, we'll have to throw this option out the window too. We've still got some tricks up our sleeve though - namely writing a bash wrapper script that will call node
telling it to execute index.mjs
with the correct arguments. After a little bit of fiddling, I came up with this:
#!/usr/bin/env bash
install_dir="$(dirname "$(readlink -f $0)")";
exec node --experimental-modules "${install_dir}/index.mjs" $@
2 things are at play here. Firstly, we have to deduce where the currently executing script actually lies - as npm uses a symbolic link to allow a global command-line tool to be 'found'. Said symbolic link gets put in /usr/local/bin/
(which is, by default, in the user's PATH), and links to where the script is actually installed to.
To figure out the directory that we've been installed to is (and hence the location of index.mjs
), we need to dereference the symbolic link and strip the index.sh
filename away. This can be done with a combination of readlink -f
(dereferences the symbolic link), dirname
(get the parent directory of a given file path), and $0
(holds the path to the currently executing script in most circumstances) - which, in the case of the above, gets put into the install_dir
variable.
The other issue is passing all the existing command-line arguments to index.mjs
unchanged. We do this with a combination of $@
(which refers to all the arguments passed to this script except the script name itself) and exec
(which replaces the currently executing process with a new one - in this case it replaces the bash
shell with node
).
This approach let's us customise the CLI arguments, while still providing global access to our script. Here's an extract from xmppbridge's package.json
showing how I specify that I want index.sh
to be a global script:
{
.....
"bin": {
"xmppbridge": "./index.sh"
},
.....
}
Bridging the Gap
Now that we've got Node calling our script correctly and the arguments parsed out, we can actually bridge the gap. This is as simple as some glue code between simple-xmpp
and readline
. simple-xmpp
is an npm package that makes programmatic XMPP interaction fairly trivial (though I did have to look at examples in the GitHub repository to figure out how to send a message to a multi-user chatroom).
readline
is a Node built-in that allows us to read the standard input line-by-line. It does other things too (and is great for interactive scripts amongst other things), but that's a tale for another time.
The first task is to create a new class for this to live in:
"use strict";
import readline from 'readline';
import xmpp from 'simple-xmpp';
class XmppBridge {
/**
* Creates a new XmppBridge instance.
* @param {string} in_login_jid The JID to login with.
* @param {string} in_destination_jid The JID to send stdin to.
* @param {Boolean} in_is_groupchat Whether the destination JID is a group chat or not.
*/
constructor(in_destination_jid, in_is_groupchat) {
// ....
}
}
export default XmppBridge;
Very cool! That was easy. Next, we need to store those arguments and connect to the XMPP server in the constructor:
this.destination_jid = in_destination_jid;
this.is_destination_groupchat = in_is_groupchat;
this.client = xmpp;
this.client.on("online", this.on_connect.bind(this));
this.client.on("error", this.on_error.bind(this));
this.client.on("chat", ((_from, _message) => {
// noop
}).bind(this));
I ended up having to define a chat
event handler - even though it's pointless, as I ran into a nasty crash if I didn't do so (I suspect that this use-case wasn't considered by the original package developer).
The next area of interest is that online
event handler. Note that I've bound the method to the current this
context - this is important, as it would be able to access the class instance's properties otherwise. Let's take a look at the code for that handler:
console.log(`[XmppBridge] Connected as ${data.jid}.`);
if(this.is_destination_groupchat) {
this.client.join(`${this.destination_jid}/bot_${data.jid.user}`);
}
this.stdin = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
this.stdin.on("line", this.on_line_handler.bind(this));
this.stdin.on("close", this.on_stdin_close_handler.bind(this));
This is the point at which we open the standard input and start listening for things to send. We don't do it earlier, as we don't want to end up in a situation where we try sending something before we're connected!
If we're supposed to be sending to a multi-user chatroom, this is also the point at which it joins said room. This is required as you can't send a message to a room that you haven't joined.
The resource (the bit after the forward slash /
), for a group chat, specifies the nickname that you want to give to yourself when joining. Here, I automatically set this to the user
part of the JID that we used to login prefixed with bot_
.
The connection itself is established in the start
method:
start(jid, password) {
this.client.connect({
jid,
password
});
}
And every time we receive a line of input, we execute the send()
method:
on_line_handler(line_text) {
this.send(line_text);
}
I used a full method here, as initially I had some issues and wanted to debug which methods were being called. That send
method looks like this:
send(message) {
this.client.send(
this.destination_jid,
message,
this.is_destination_groupchat
);
}
The last event handler worth mentioning is the close
event handler on the readline
interface:
on_stdin_close_handler() {
this.client.disconnect();
}
This just disconnects from the XMXPP server so that Node can exit cleanly.
That basically completes the script. In total, the entire XmppBridge.mjs
class file is 72 lines. Not bad going!
You can install this tool for yourself with sudo npm install -g xmppbridge
. I've documented how it use it in the README, so I'd recommend heading over there if you're interested in trying it out.
Found this interesting? Got a cool use for XMPP? Comment below!
Sources and Further Reading