sgx-catapult.rb

  1#!/usr/bin/env ruby
  2#
  3# Copyright (C) 2017  Denver Gingerich <denver@ossguy.com>
  4# Copyright (C) 2017  Stephen Paul Weber <singpolyma@singpolyma.net>
  5#
  6# This file is part of sgx-catapult.
  7#
  8# sgx-catapult is free software: you can redistribute it and/or modify it under
  9# the terms of the GNU Affero General Public License as published by the Free
 10# Software Foundation, either version 3 of the License, or (at your option) any
 11# later version.
 12#
 13# sgx-catapult is distributed in the hope that it will be useful, but WITHOUT
 14# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 15# FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
 16# details.
 17#
 18# You should have received a copy of the GNU Affero General Public License along
 19# with sgx-catapult.  If not, see <http://www.gnu.org/licenses/>.
 20
 21require 'blather/client/dsl'
 22require 'json'
 23require 'net/http'
 24require 'redis/connection/hiredis'
 25require 'uri'
 26
 27require 'goliath/api'
 28require 'goliath/server'
 29require 'log4r'
 30
 31if ARGV.size != 8 then
 32	puts "Usage: sgx-catapult.rb <component_jid> <component_password> " +
 33		"<server_hostname> <server_port> " +
 34		"<redis_hostname> <redis_port> <delivery_receipt_url> " +
 35		"<http_listen_port>"
 36	exit 0
 37end
 38
 39module SGXcatapult
 40	extend Blather::DSL
 41
 42	def self.run
 43		client.run
 44	end
 45
 46	# so classes outside this module can write messages, too
 47	def self.write(stanza)
 48		client.write(stanza)
 49	end
 50
 51	def self.error_msg(orig, query_node, type, name, text = nil)
 52		if not query_node.nil?
 53			orig.add_child(query_node)
 54			orig.type = :error
 55		end
 56
 57		error = Nokogiri::XML::Node.new 'error', orig.document
 58		error['type'] = type
 59		orig.add_child(error)
 60
 61		suberr = Nokogiri::XML::Node.new name, orig.document
 62		suberr['xmlns'] = 'urn:ietf:params:xml:ns:xmpp-stanzas'
 63		error.add_child(suberr)
 64
 65		# TODO: add some explanatory xml:lang='en' text (see text param)
 66		puts "RESPONSE3: #{orig.inspect}"
 67		return orig
 68	end
 69
 70	setup ARGV[0], ARGV[1], ARGV[2], ARGV[3]
 71
 72	message :chat?, :body do |m|
 73		num_dest = m.to.to_s.split('@', 2)[0]
 74
 75		if num_dest[0] != '+'
 76			# TODO: add text re number not (yet) supported/implmnted
 77			write_to_stream error_msg(m.reply, m.body, :modify,
 78				'policy-violation')
 79			next
 80		end
 81
 82		bare_jid = m.from.to_s.split('/', 2)[0]
 83		cred_key = "catapult_cred-" + bare_jid
 84
 85		conn = Hiredis::Connection.new
 86		conn.connect(ARGV[4], ARGV[5].to_i)
 87
 88		conn.write ["EXISTS", cred_key]
 89		if conn.read == 0
 90			conn.disconnect
 91
 92			# TODO: add text re credentials not being registered
 93			write_to_stream error_msg(m.reply, m.body, :auth,
 94				'registration-required')
 95			next
 96		end
 97
 98		conn.write ["LRANGE", cred_key, 0, 3]
 99		creds = conn.read
100		conn.disconnect
101
102		uri = URI.parse('https://api.catapult.inetwork.com')
103		http = Net::HTTP.new(uri.host, uri.port)
104		http.use_ssl = true
105		request = Net::HTTP::Post.new('/v1/users/' + creds[0] +
106			'/messages')
107		request.basic_auth creds[1], creds[2]
108		request.add_field('Content-Type', 'application/json')
109		request.body = JSON.dump({
110			'from'			=> creds[3],
111			'to'			=> num_dest,
112			'text'			=> m.body,
113			'tag'			=> m.id, # TODO: message has it?
114			'receiptRequested'	=> 'all',
115			'callbackUrl'		=> ARGV[6]
116		})
117		response = http.request(request)
118
119		puts 'API response to send: ' + response.to_s + ' with code ' +
120			response.code + ', body "' + response.body + '"'
121
122		if response.code != '201'
123			# TODO: add text re unexpected code; mention code number
124			write_to_stream error_msg(m.reply, m.body, :cancel,
125				'internal-server-error')
126			next
127		end
128
129		# TODO: don't echo message; leave in until we rcv msgs properly
130		begin
131			puts "#{m.from.to_s} -> #{m.to.to_s} #{m.body}"
132			msg = Blather::Stanza::Message.new(m.from, 'thx for "' +
133				m.body + '"')
134			msg.from = m.to
135			write_to_stream msg
136		rescue => e
137			# TODO: do something better with this info
138			say m.from, e.inspect
139		end
140	end
141
142	def self.user_cap_identities()
143		[{:category => 'client', :type => 'sms'}]
144	end
145
146	def self.user_cap_features()
147		# TODO: add more features
148		["urn:xmpp:receipts"]
149	end
150
151	presence :subscribe? do |p|
152		puts "PRESENCE1: #{p.inspect}"
153
154		msg = Blather::Stanza::Presence.new
155		msg.to = p.from
156		msg.from = p.to
157		msg.type = :subscribed
158
159		puts "RESPONSE5: #{msg.inspect}"
160		write_to_stream msg
161	end
162
163	presence :probe? do |p|
164		puts 'PRESENCE2: ' + p.inspect
165
166		caps = Blather::Stanza::Capabilities.new
167		# TODO: user a better node URI (?)
168		caps.node = 'http://catapult.sgx.soprani.ca/'
169		caps.identities = user_cap_identities()
170		caps.features = user_cap_features()
171
172		msg = caps.c
173		msg.to = p.from
174		msg.from = p.to.to_s + '/sgx'
175
176		puts 'RESPONSE6: ' + msg.inspect
177		write_to_stream msg
178	end
179
180	iq '/iq/ns:query', :ns =>
181		'http://jabber.org/protocol/disco#items' do |i, xpath_result|
182
183		write_to_stream i.reply
184	end
185
186	iq '/iq/ns:query', :ns =>
187		'http://jabber.org/protocol/disco#info' do |i, xpath_result|
188
189		if i.to.to_s.include? '@'
190			# TODO: confirm the node URL is expected using below
191			#puts "XR[node]: #{xpath_result[0]['node']}"
192
193			msg = i.reply
194			msg.identities = user_cap_identities()
195			msg.features = user_cap_features()
196
197			puts 'RESPONSE7: ' + msg.inspect
198			write_to_stream msg
199			next
200		end
201
202		msg = i.reply
203		msg.identities = [{:name =>
204			'Soprani.ca Gateway to XMPP - Catapult',
205			:type => 'sms-ctplt', :category => 'gateway'}]
206		msg.features = ["jabber:iq:register",
207			"jabber:iq:gateway", "jabber:iq:private",
208			"http://jabber.org/protocol/disco#info",
209			"http://jabber.org/protocol/commands",
210			"http://jabber.org/protocol/muc"]
211		write_to_stream msg
212	end
213
214	iq '/iq/ns:query', :ns => 'jabber:iq:register' do |i, qn|
215		puts "IQ: #{i.inspect}"
216
217		if i.type == :set
218			xn = qn.children.find { |v| v.element_name == "x" }
219
220			user_id = ''
221			api_token = ''
222			api_secret = ''
223			phone_num = ''
224
225			if xn.nil?
226				user_id = qn.children.find {
227					|v| v.element_name == "nick" }
228				api_token = qn.children.find {
229					|v| v.element_name == "username" }
230				api_secret = qn.children.find {
231					|v| v.element_name == "password" }
232				phone_num = qn.children.find {
233					|v| v.element_name == "phone" }
234			else
235				for field in xn.children
236					if field.element_name == "field"
237						val = field.children.find { |v|
238						v.element_name == "value" }
239
240						case field['var']
241						when 'nick'
242							user_id = val.text
243						when 'username'
244							api_token = val.text
245						when 'password'
246							api_secret = val.text
247						when 'phone'
248							phone_num = val.text
249						else
250							# TODO: error
251							puts "?: " +field['var']
252						end
253					end
254				end
255			end
256
257			if phone_num[0] != '+'
258				# TODO: add text re number not (yet) supported
259				write_to_stream error_msg(i.reply, qn, :modify,
260					'policy-violation')
261				next
262			end
263
264			uri = URI.parse('https://api.catapult.inetwork.com')
265			http = Net::HTTP.new(uri.host, uri.port)
266			http.use_ssl = true
267			request = Net::HTTP::Get.new('/v1/users/' + user_id +
268				'/phoneNumbers/' + phone_num)
269			request.basic_auth api_token, api_secret
270			response = http.request(request)
271
272			puts 'API response: ' + response.to_s + ' with code ' +
273				response.code + ', body "' + response.body + '"'
274
275			if response.code == '200'
276				params = JSON.parse response.body
277				if params['numberState'] == 'enabled'
278					num_key = "catapult_num-" + phone_num
279
280					bare_jid = i.from.to_s.split('/', 2)[0]
281					cred_key = "catapult_cred-" + bare_jid
282
283					# TODO: pre-validate ARGV[5] is integer
284					conn = Hiredis::Connection.new
285					conn.connect(ARGV[4], ARGV[5].to_i)
286
287					conn.write ["EXISTS", num_key]
288					if conn.read == 1
289						conn.disconnect
290
291						# TODO: add txt re num exists
292						write_to_stream error_msg(
293							i.reply, qn, :cancel,
294							'conflict')
295						next
296					end
297
298					conn.write ["EXISTS", cred_key]
299					if conn.read == 1
300						conn.disconnect
301
302						# TODO: add txt re already exist
303						write_to_stream error_msg(
304							i.reply, qn, :cancel,
305							'conflict')
306						next
307					end
308
309					conn.write ["RPUSH",num_key,bare_jid]
310					if conn.read != 1
311						conn.disconnect
312
313						# TODO: catch/relay RuntimeError
314						# TODO: add txt re push failure
315						write_to_stream error_msg(
316							i.reply, qn, :cancel,
317							'internal-server-error')
318						next
319					end
320
321					conn.write ["RPUSH",cred_key,user_id]
322					conn.write ["RPUSH",cred_key,api_token]
323					conn.write ["RPUSH",cred_key,api_secret]
324					conn.write ["RPUSH",cred_key,phone_num]
325
326					for n in 1..4 do
327						# TODO: catch/relay RuntimeError
328						result = conn.read
329						if result != n
330							conn.disconnect
331
332							write_to_stream(
333							error_msg(
334							i.reply, qn, :cancel,
335							'internal-server-error')
336							)
337							next
338						end
339					end
340					conn.disconnect
341
342					write_to_stream i.reply
343				else
344					# TODO: add text re number disabled
345					write_to_stream error_msg(i.reply, qn,
346						:modify, 'not-acceptable')
347				end
348			elsif response.code == '401'
349				# TODO: add text re bad credentials
350				write_to_stream error_msg(i.reply, qn, :auth,
351					'not-authorized')
352			elsif response.code == '404'
353				# TODO: add text re number not found or disabled
354				write_to_stream error_msg(i.reply, qn, :cancel,
355					'item-not-found')
356			else
357				# TODO: add text re misc error, and mention code
358				write_to_stream error_msg(i.reply, qn, :modify,
359					'not-acceptable')
360			end
361
362		elsif i.type == :get
363			orig = i.reply
364
365			msg = Nokogiri::XML::Node.new 'query',orig.document
366			msg['xmlns'] = 'jabber:iq:register'
367			n1 = Nokogiri::XML::Node.new 'instructions',msg.document
368			n1.content= "Enter the information from your Account " +
369				"page as well as the Phone Number\nin your " +
370				"account you want to use (ie. '+12345678901')" +
371				".\nUser Id is nick, API Token is username, " +
372				"API Secret is password, Phone Number is phone"+
373				".\n\nThe source code for this gateway is at " +
374				"https://github.com/ossguy/sgx-catapult ." +
375				"\nCopyright (C) 2017  Denver Gingerich, " +
376				"licensed under AGPLv3+."
377			n2 = Nokogiri::XML::Node.new 'nick',msg.document
378			n3 = Nokogiri::XML::Node.new 'username',msg.document
379			n4 = Nokogiri::XML::Node.new 'password',msg.document
380			n5 = Nokogiri::XML::Node.new 'phone',msg.document
381			msg.add_child(n1)
382			msg.add_child(n2)
383			msg.add_child(n3)
384			msg.add_child(n4)
385			msg.add_child(n5)
386
387			x = Blather::Stanza::X.new :form, [
388				{:required => true, :type => :"text-single",
389				:label => 'User Id', :var => 'nick'},
390				{:required => true, :type => :"text-single",
391				:label => 'API Token', :var => 'username'},
392				{:required => true, :type => :"text-private",
393				:label => 'API Secret', :var => 'password'},
394				{:required => true, :type => :"text-single",
395				:label => 'Phone Number', :var => 'phone'}
396			]
397			x.title= 'Register for ' +
398				'Soprani.ca Gateway to XMPP - Catapult'
399			x.instructions= "Enter the details from your Account " +
400				"page as well as the Phone Number\nin your " +
401				"account you want to use (ie. '+12345678901')" +
402				".\n\nThe source code for this gateway is at " +
403				"https://github.com/ossguy/sgx-catapult ." +
404				"\nCopyright (C) 2017  Denver Gingerich, " +
405				"licensed under AGPLv3+."
406			msg.add_child(x)
407
408			orig.add_child(msg)
409			puts "RESPONSE2: #{orig.inspect}"
410			write_to_stream orig
411			puts "SENT"
412		end
413	end
414
415	subscription(:request?) do |s|
416		# TODO: are these the best to return?  really need '!' here?
417		#write_to_stream s.approve!
418		#write_to_stream s.request!
419	end
420end
421
422[:INT, :TERM].each do |sig|
423	trap(sig) {
424		puts 'Shutting down gateway...'
425		SGXcatapult.shutdown
426		puts 'Gateway has terminated.'
427
428		EM.stop
429	}
430end
431
432class ReceiptMessage < Blather::Stanza
433	def self.new(to = nil)
434		node = super :message
435		node.to = to
436		node
437	end
438end
439
440class WebhookHandler < Goliath::API
441	def response(env)
442		puts 'ENV: ' + env.to_s
443		body = Rack::Request.new(env).body.read
444		puts 'BODY: ' + body
445		params = JSON.parse body
446
447		users_num = ''
448		others_num = ''
449		if params['direction'] == 'in'
450			users_num = params['to']
451			others_num = params['from']
452		elsif params['direction'] == 'out'
453			users_num = params['from']
454			others_num = params['to']
455		else
456			# TODO: exception or similar
457			puts "big problem: '" + params['direction'] + "'"
458			return [200, {}, "OK"]
459		end
460
461		num_key = "catapult_num-" + users_num
462
463		# TODO: validate that others_num starts with '+' or is shortcode
464
465		conn = Hiredis::Connection.new
466		conn.connect(ARGV[4], ARGV[5].to_i)
467
468		conn.write ["EXISTS", num_key]
469		if conn.read == 0
470			conn.disconnect
471
472			puts "num_key (#{num_key}) DNE; Catapult misconfigured?"
473
474			# TODO: likely not appropriate; give error to Catapult?
475			# TODO: add text re credentials not being registered
476			#write_to_stream error_msg(m.reply, m.body, :auth,
477			#	'registration-required')
478			return [200, {}, "OK"]
479		end
480
481		conn.write ["LRANGE", num_key, 0, 0]
482		bare_jid = conn.read[0]
483		conn.disconnect
484
485		msg = ''
486		case params['direction']
487		when 'in'
488			text = ''
489			case params['eventType']
490			when 'sms'
491				text = params['text']
492			when 'mms'
493				text = "MMS (pic not implemented) with text: " +
494					params['text']
495			else
496				text = "unknown type (#{params['eventType']})" +
497					" with text: " + params['text']
498
499				# TODO log/notify of this properly
500				puts text
501			end
502
503			msg = Blather::Stanza::Message.new(bare_jid, text)
504		else # per prior switch, this is:  params['direction'] == 'out'
505			msg = ReceiptMessage.new(bare_jid)
506			msg['id'] = params['tag']
507
508			case params['deliveryState']
509			when 'not-delivered'
510				# TODO: add text re deliveryDescription reason
511				msg = SGXcatapult.error_msg(msg, nil, :cancel,
512					'service-unavailable')
513				return [200, {}, "OK"]
514			when 'delivered'
515				# TODO: send only when requested per XEP-0184
516				rcvd = Nokogiri::XML::Node.new 'received',
517					msg.document
518				rcvd['xmlns'] = 'urn:xmpp:receipts'
519				rcvd['id'] = params['tag']
520				msg.add_child(rcvd)
521			when 'waiting'
522				# can't really do anything with it; nice to know
523				puts "message with id #{params['id']} waiting"
524				return [200, {}, "OK"]
525			else
526				# TODO: notify somehow of unknown state receivd?
527				puts "message with id #{params['id']} has " +
528					"other state #{params['deliveryState']}"
529				return [200, {}, "OK"]
530			end
531
532			puts "RESPONSE4: #{msg.inspect}"
533		end
534
535		msg.from = others_num + '@' + ARGV[0]
536		SGXcatapult.write(msg)
537
538		[200, {}, "OK"]
539	end
540end
541
542EM.run do
543	SGXcatapult.run
544
545	server = Goliath::Server.new('0.0.0.0', ARGV[7].to_i)
546	server.api = WebhookHandler.new
547	server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
548	server.logger = Log4r::Logger.new('goliath')
549	server.logger.add(Log4r::StdoutOutputter.new('console'))
550	server.logger.level = Log4r::INFO
551	server.start
552end