Sending HTML messages with Net::XMPP (Perl)

Sat, 17 Aug 2019 08:15:00 +0200
debian, hacks

Executive summary

It’s perfectly possible! Jump to the HTML demo!

Longer version

This started with a very simple need: wanting to improve the notifications I’m receiving from various sources. Those include:

changes or failures reported during Puppet runs on my own infrastructure, and on at a customer’s;

build failures for the Debian Installer;

changes in banking amounts;

and lately: build status for jobs in a customer’s Jenkins instance.

I’ve been using plaintext notifications for a number of years but I decided to try and pimp them a little by adding some colors.

While the XMPP-sending details are usually hidden in a local module, here’s a small self-contained example: connecting to a server, sending credentials, and then sending a message to someone else. Of course, one might want to tweak the Configuration section before trying to run this script…

#!/usr/bin/perl
use strict;
use warnings;

use Net::XMPP;

# Configuration:
my $hostname = 'example.org';
my $username = 'bot';
my $password = 'call-me-alan';
my $resource = 'demo';
my $recipient = 'human@example.org';

# Open connection:
my $con = Net::XMPP::Client->new();
my $status = $con->Connect(
    hostname       => $hostname,
    connectiontype => 'tcpip',
    tls            => 1,
    ssl_ca_path    => '/etc/ssl/certs',
);
die 'XMPP connection failed'
    if ! defined($status);

# Log in:
my @result = $con->AuthSend(
    hostname => $hostname,
    username => $username,
    password => $password,
    resource => $resource,
);
die 'XMPP authentication failed'
    if $result[0] ne 'ok';

# Send plaintext message:
my $msg = 'Hello, World!';
my $res = $con->MessageSend(
    to   => $recipient,
    body => $msg,
    type => 'chat',
);
die('ERROR: XMPP message failed')
    if $res != 0;

For reference, here’s what the XML message looks like in Gajim’s XML console (on the receiving end):

<message type='chat' to='human@example.org' from='bot@example.org/demo'>
Hello, World!
</message>

Issues start when one tries to send some HTML message, e.g. with the last part changed to:

# Send plaintext message:
my $msg = 'This is a failing test';
my $res = $con->MessageSend(
    to   => $recipient,
    body => $msg,
    type => 'chat',
);

as that leads to the following message:

<message type='chat' to='human@example.org' from='bot@example.org/demo'>
This is a failing test
</message>

So tags are getting encoded and one gets to see the uninterpreted “HTML code”.

Trying various things to embed that inside and tags, with or without namespaces, led nowhere.

Looking at a message sent from Gajim to Gajim (so that I could craft an HTML message myself and inspect it), I’ve noticed it goes this way (edited to concentrate on important parts):

<message xmlns="jabber:client" to="human@example.org/Gajim" type="chat">
Hello, World!


Hello, World!


</message>

Two takeaways here:

The message is send both in plaintext and in HTML. It seems Gajim archives the plaintext version, as opening the history/logs only shows the textual version.

The fact that the HTML message is under a different path (/message/html as opposed to /message/body) means that one cannot use the MessageSend method to send HTML messages…

This was verified by checking the documentation and code of the Net::XMPP::Message module. It comes with various getters and setters for attributes. Those are then automatically collected when the message is serialized into XML (through the GetXML() method). Trying to add handling for a new HTML attribute would mean being extra careful as that would need to be treated with $type = 'raw'…

Oh, wait a minute! While using git grep in the sources, looking for that raw type thing, I’ve discovered what sounded promising: an InsertRawXML() method, that doesn’t appear anywhere in either the code or the documentation of the Net::XMPP::Message module.

It’s available, though! Because Net::XMPP::Message is derived from Net::XMPP::Stanza:

use Net::XMPP::Stanza;
use base qw( Net::XMPP::Stanza );

which then in turn comes with this function:

##############################################################################
#
# InsertRawXML - puts the specified string onto the list for raw XML to be
#                included in the packet.
#
##############################################################################

Let’s put that aside for a moment and get back to the MessageSend() method. It wants parameters that can be passed to the Net::XMPP::Message SetMessage() method, and here is its entire code:

###############################################################################
#
# MessageSend - Takes the same hash that Net::XMPP::Message->SetMessage
#               takes and sends the message to the server.
#
###############################################################################
sub MessageSend
{
    my $self = shift;

    my $mess = $self->_message();
    $mess->SetMessage(@_);
    $self->Send($mess);
}

The first assignment is basically equivalent to my $mess = Net::XMPP::Message->new();, so what this function does is: creating a Net::XMPP::Message for us, passing all parameters there, and handing the resulting object over to the Send() method. All in all, that’s merely a proxy.

HTML demo

The question becomes: what if we were to create that object ourselves, then tweaking it a little, and then passing it directly to Send(), instead of using the slightly limited MessageSend()? Let’s see what the rewritten sending part would look like:

# Send HTML message:
my $text = 'This is a working test';
my $html = 'This is a working test';

my $message = Net::XMPP::Message->new();
$message->SetMessage(
    to   => $recipient,
    body => $text,
    type => 'chat',
);
$message->InsertRawXML(" $html ");
my $res = $con->Send($message);

And tada!

<message type='chat' to='human@example.org' from='bot@example.org/demo'>
This is a working test

This is a working test

</message>

I’m absolutely no expert when it comes to XMPP standards, and one might need/want to set some more metadata like xmlns but I’m happy enough with this solution that I thought I’d share it as is. ;)