It’s been a while since I wrote about XMPP and XMPP4r. I’m glad because my introductory posts on this topic were very well received. Today I want to push further and share with you a tutorial-like post explaining step by step how to build a simple application with a XMPP interface. The complete listing is also available on bitbucket
The application
I couldn’t find something better than writing a xmpp weather bot. You type the name of a city and the bot answers with the current temperature and forecast information using the Google Weather API. Simple enough for this tutorial.
The gems you’ll need to install
- XMPP4r : sudo gem install xmpp4r
- json_pure, to convert ruby hashes into json: sudo gem install json_pure
- Crack, a xml and json parser: sudo gem install crack
- starling, a ruby message queue: sudo gem sources -a http://gems.github.com/ && sudo gem install starling-starling Then, start the starling daemon by typing : sudo starling -d
Why using a message queue like starling?
starling is a message queue library that speaks the memcache protocol. It is my opinion that a message queue is often a very clever and powerful way to establish a direct communication between the various parts of an application or even between different applications. For the sake of this tutorial, I wanted to separate our application into two distinct components and I didn’t want these components to talk to each other via the http protocol.
We won’t install a XMPP server
We won’t go to the pain of installing a XMPP server like ejabberd or openfire because it would be beyond the scope of this article. Instead, we’ll simply use a Google account for our bot
Application architecture
Like I said, I wanted to separate the application into 2 components : The xmpp listener and the backend logic. You probably already guessed that the xmpp listener component job will be to send and receive xmpp stanzas to the user at the other end. To get the weather data, our listener will send the request to the backend component via a starling message queue that we’ll name backend. The backend component will do what is needed to compute the weather data and it will send the result back to the listener component via another starling message queue that we’ll name listener
This setup might be overkill for our simple app but one of my objective was to show you how easy and interesting it can be to work with message queues. Let’s get the ball rolling.
The listener component
create a new file in your code editor and give it the name listener.rb. Here is the complete source code for this file. I will add comments at the bottom of the listing. To copy this code in your clipboard, don’t forget to click on the small “view plain” link at the top or else you will copy the line numbers as well.
[ruby]
require ‘starling’
require ‘json/pure’
require ‘xmpp4r’
require ‘xmpp4r/roster’
require ‘cgi’
class Fleebie
include Jabber
attr_accessor :jid, :password
attr_reader :client, :roster
def initialize
self.jid = ARGV[0]
self.password = ARGV[1]
@client = Client.new(self.jid)
Jabber::debug = true
connect
end
def connect
@client.connect
@client.auth(@password)
@client.send(Presence.new.set_type(:available))
#the "roster" is our bot contact list
@roster = Roster::Helper.new(@client)
#…to accept new subscriptions
start_subscription_callback
#…to do something with the messages we receive
start_message_callback
#When the backend application has done its job, it tells the listener
#via the "listener" message queue.
process_queue
end
private
#Whatever we receive, we send it to our "backend" message queue. It’s
#not our job to parse and decode the actual message
def start_message_callback
@client.add_message_callback do |m|
@starling.set(‘backend’,{:from => m.from, :body => m.body}.to_json)
unless m.composing? || m.body.to_s.strip == ""
end
end
#whenever someone adds the bot to his contact list, it gets here
def start_subscription_callback
@roster.add_subscription_request_callback do |item,pres|
#we accept everyone
@roster.accept_subscription(pres.from)
#Now it’s our turn to send a subscription request
x = Presence.new.set_type(:subscribe).set_to(pres.from)
@client.send(x)
#let’s greet our new user
m=Message::new
m.to = pres.from
m.body = "Welcome! Type a location to get the weather forecast"
@client.send(m)
end
end
#The backend application talks to this XMPP interface via starling.
#in process_queue we process our job list.
def process_queue
@starling = Starling.new(‘127.0.0.1:22122’)
th = Thread.new do
Thread.current.abort_on_exception = true
loop do
item = @starling.get(‘listener’)
unless item.nil?
jitem = JSON.parse(item) rescue nil
msg = Message::new(jitem["from"])
msg.type=:chat
if jitem["success"] == true
msg.body = "\n"
msg.body += jitem["message"] + "\n"
msg.body += "Current temp: #{jitem["details"]["current_temperature"]}\n"
msg.body += "Winds: #{jitem["details"]["winds"]}\n\n"
msg.body += "<b>TODAY</b>\n"
msg.body += jitem["details"]["today"]["condition"] + "\n"
msg.body += "Min/Max : #{jitem["details"]["today"]["low_f"]} / "
msg.body += jitem["details"]["today"]["high_f"] + " ("
msg.body += jitem["details"]["today"]["low_c"] + " / "
msg.body += jitem["details"]["today"]["high_c"] + ") \n\n"
msg.body += "<b>TOMORROW</b>\n"
msg.body += jitem["details"]["tomorrow"]["condition"] + "\n"
msg.body += "Min/Max : #{jitem["details"]["tomorrow"]["low_f"]} /"
msg.body += jitem["details"]["tomorrow"]["high_f"] + " ("
msg.body += jitem["details"]["tomorrow"]["low_c"] + " / "
msg.body += jitem["details"]["tomorrow"]["high_c"] + ") \n"
msg.add_element(prepare_html(msg.body))
msg.body = msg.body.gsub(/<.*?>/, ”)
else
msg.body = jitem["message"]
end
@client.send(msg)
end
end
end
end
def prepare_html(text)
h = REXML::Element::new("html")
h.add_namespace(‘http://jabber.org/protocol/xhtml-im’)
# The body part with the correct namespace
b = REXML::Element::new("body")
b.add_namespace(‘http://www.w3.org/1999/xhtml’)
# The html itself
t = REXML::Text.new(text.gsub("\n","<br />"), false, nil, true, nil, %r/.^/ )
# Add the html text to the body, and the body to the html element
b.add(t)
h.add(b)
h
end
end
Fleebie.new
Thread.stop
[/ruby]
Important parts of this listing
I hope that you will find the code above self explaining. However, here are a few important things about the listing. At line 98-99, we set 2 versions of the same message that will be sent to the XMPP user : one in plain text, the other in XHTML. If the client at the other end has support for XHTML messages, the XHTML version will be displayed, otherwise the plain text version will be used. This is a pretty interesting feature of the XMPP protocol.
The process_queue method
We start a new thread, we subscribe to the ‘listener’ message queue and we process our job list until the program ends or gets interrupted (by typing Ctrl-C for example). You can see that by using a message queue, we move away from the traditional “Request / Response” paradigm. In our case the request (line #44) is “disconnected” from the response (line #103).
Finally, at line #129 we stop the main thread (not the same thread that we started in process_queue). We do this for an obvious reason : we don’t want our script to terminate once it will reach the end of the file. We just ask him to wait until the other threads are done executing.
The backend component
It is here that we will fetch, compute and prepare the actual weather data. For the sake of this tutorial I use the google weather API but it could have been anything else. Create a new file in your code editor and call it backend.rb
require 'starling' require 'crack' require 'net/http' require 'cgi' require 'iconv' require 'json' class Backend def run process_queue end private def process_queue @starling = Starling.new('127.0.0.1:22122') th = Thread.new do Thread.current.abort_on_exception = true loop do item = @starling.get('backend') unless item.nil? jitem = Crack::JSON.parse(item) rescue nil google_api_url = "http://www.google.com/ig/api?weather=#{CGI::escape(jitem["body"])}" ig_weather = Crack::XML.parse(Iconv.conv('UTF-8', 'ISO-8859-1', Net::HTTP.get(URI.parse(google_api_url)) ) ) rescue nil if jitem && ig_weather process_job(jitem,ig_weather) else puts jitem["from"] @starling.set('listener', { :from => jitem["from"], :success => false, :message => "An error occured while accessing Google Weather API. You may try again later" }.to_json) unless jitem.nil? end end end end end def process_job(jitem,ig_weather) if ig_weather["xml_api_reply"]["weather"]["problem_cause"] @starling.set('listener',{ :from => jitem["from"], :success => false, :message => "Data not available. Try being more precise when typing your location (ex. trois-rivières, québec)" }.to_json) else weather = ig_weather["xml_api_reply"]["weather"] data = { :forecast_obj => weather["forecast_conditions"], :city => weather["forecast_information"]["city"]["data"], :winds => weather["current_conditions"]["wind_condition"]["data"], :unit_system => weather["forecast_information"]["unit_system"]["data"], :temp_f => weather["current_conditions"]["temp_f"]["data"], :temp_c => weather["current_conditions"]["temp_c"]["data"], } @starling.set('listener', { :from => jitem["from"], :success => true, :message => "Weather data for #{data[:city]}:", :details => { :current_temperature => "#{data[:temp_f]} ° F / #{data[:temp_c]} ° C", :unit => data[:unit_system], :winds => data[:winds], :today => temperatures( data[:unit_system], data[:forecast_obj][0] ).merge(:condition => data[:forecast_obj][0]["condition"]["data"]), :tomorrow => temperatures( data[:unit_system], data[:forecast_obj][1] ).merge(:condition => data[:forecast_obj][1]["condition"]["data"]) } }.to_json) end end def temperatures(source_unit,obj) x = {} if(source_unit == "US") #we convert to Celcius x["low_c"] = obj["low"]["data"].to_i.to_celcius x["high_c"] = obj["high"]["data"].to_i.to_celcius x["low_f"] = "#{obj["low"]["data"]} ° F" x["high_f"] = "#{obj["high"]["data"]} ° F" else #we convert to farenheit x["low_f"] = obj["low"]["data"].to_i.to_farenheit x["high_f"] = obj["high"]["data"].to_i.to_farenheit x["low_c"] = "#{obj["low"]["data"]} ° C" x["high_c"] = "#{obj["high"]["data"]} ° C" end x end end class Fixnum def to_celcius ((self - 32) / 1.8).round.to_s + " °C" end def to_farenheit (self * 1.8 + 32).round.to_s + " °F" end end Backend.new.run #Always remember to pause the main thread at the end since it does not contain any #blocking call. Thread.stop
This code is not very sexy… It just queries the google api, parse the result, convert temperatures from Farenheit to Celcius or the other way around (because the Google API doesn’t do that by itself) and send back the response to the listener component via the corresponding message queue.
Launch the script
Create a shell script and call it launcher.sh (don’t forget to chmod +x). put the following in it :
#!/bin/bash export RUBYOPT=rubygems d=`dirname $0` ruby $d/backend.rb & ruby $d/listener.rb [email protected] somepassword
Now you can type ./launcher.sh and you should be ready to go. If you encounter a problem while following the tutorial, don’t hesitate to post a comment and I’ll do my best to help you resolve it.