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 'time'
 26require 'uri'
 27require 'uuid'
 28
 29require 'goliath/api'
 30require 'goliath/server'
 31require 'log4r'
 32
 33puts "Soprani.ca/SMS Gateway for XMPP - Catapult        v0.016"
 34
 35if ARGV.size != 9 then
 36	puts "Usage: sgx-catapult.rb <component_jid> <component_password> " +
 37		"<server_hostname> <server_port> " +
 38		"<redis_hostname> <redis_port> <delivery_receipt_url> " +
 39		"<http_listen_port> <mms_proxy_prefix_url>"
 40	exit 0
 41end
 42
 43module SGXcatapult
 44	extend Blather::DSL
 45
 46	@jingle_sids = Hash.new
 47	@jingle_fnames = Hash.new
 48	@partial_data = Hash.new
 49	@uuid_gen = UUID.new
 50
 51	def self.run
 52		client.run
 53	end
 54
 55	# so classes outside this module can write messages, too
 56	def self.write(stanza)
 57		client.write(stanza)
 58	end
 59
 60	def self.error_msg(orig, query_node, type, name, text = nil)
 61		if not query_node.nil?
 62			orig.add_child(query_node)
 63			orig.type = :error
 64		end
 65
 66		error = Nokogiri::XML::Node.new 'error', orig.document
 67		error['type'] = type
 68		orig.add_child(error)
 69
 70		suberr = Nokogiri::XML::Node.new name, orig.document
 71		suberr['xmlns'] = 'urn:ietf:params:xml:ns:xmpp-stanzas'
 72		error.add_child(suberr)
 73
 74		# TODO: add some explanatory xml:lang='en' text (see text param)
 75		puts "RESPONSE3: #{orig.inspect}"
 76		return orig
 77	end
 78
 79	setup ARGV[0], ARGV[1], ARGV[2], ARGV[3]
 80
 81	message :chat?, :body do |m|
 82		num_dest = m.to.to_s.split('@', 2)[0]
 83
 84		if num_dest[0] != '+'
 85			# check to see if a valid shortcode context is specified
 86			num_and_context = num_dest.split(';', 2)
 87			if num_and_context[1] and num_and_context[1] ==
 88				'phone-context=ca-us.phone-context.soprani.ca'
 89
 90				# TODO: check if num_dest is fully numeric
 91				num_dest = num_and_context[0]
 92			else
 93				# TODO: text re num not (yet) supportd/implmentd
 94				write_to_stream error_msg(m.reply, m.body,
 95					:cancel, 'item-not-found')
 96				next
 97			end
 98		end
 99
100		bare_jid = m.from.to_s.split('/', 2)[0]
101		cred_key = "catapult_cred-" + bare_jid
102
103		conn = Hiredis::Connection.new
104		conn.connect(ARGV[4], ARGV[5].to_i)
105
106		conn.write ["EXISTS", cred_key]
107		if conn.read == 0
108			conn.disconnect
109
110			# TODO: add text re credentials not being registered
111			write_to_stream error_msg(m.reply, m.body, :auth,
112				'registration-required')
113			next
114		end
115
116		conn.write ["LRANGE", cred_key, 0, 3]
117		user_id, api_token, api_secret, users_num = conn.read
118		conn.disconnect
119
120		uri = URI.parse('https://api.catapult.inetwork.com')
121		http = Net::HTTP.new(uri.host, uri.port)
122		http.use_ssl = true
123		request = Net::HTTP::Post.new('/v1/users/' + user_id +
124			'/messages')
125		request.basic_auth api_token, api_secret
126		request.add_field('Content-Type', 'application/json')
127		request.body = JSON.dump({
128			'from'			=> users_num,
129			'to'			=> num_dest,
130			'text'			=> m.body,
131			'tag'			=> m.id, # TODO: message has it?
132			'receiptRequested'	=> 'all',
133			'callbackUrl'		=> ARGV[6]
134		})
135		response = http.request(request)
136
137		puts 'API response to send: ' + response.to_s + ' with code ' +
138			response.code + ', body "' + response.body + '"'
139
140		if response.code != '201'
141			# TODO: add text re unexpected code; mention code number
142			write_to_stream error_msg(m.reply, m.body, :cancel,
143				'internal-server-error')
144			next
145		end
146	end
147
148	def self.user_cap_identities()
149		[{:category => 'client', :type => 'sms'}]
150	end
151
152	def self.user_cap_features()
153	[
154		"urn:xmpp:receipts",
155		"urn:xmpp:jingle:1", "urn:xmpp:jingle:transports:ibb:1",
156
157		# TODO: eventually add more efficient file transfer mechanisms
158		#"urn:xmpp:jingle:transports:s5b:1",
159
160		# TODO: MUST add all relevant/reasonable vers of file-transfer
161		#"urn:xmpp:jingle:apps:file-transfer:4"
162		"urn:xmpp:jingle:apps:file-transfer:3"
163	]
164	end
165
166	presence :subscribe? do |p|
167		puts "PRESENCE1: #{p.inspect}"
168
169		msg = Blather::Stanza::Presence.new
170		msg.to = p.from
171		msg.from = p.to
172		msg.type = :subscribed
173
174		puts "RESPONSE5: #{msg.inspect}"
175		write_to_stream msg
176	end
177
178	presence :probe? do |p|
179		puts 'PRESENCE2: ' + p.inspect
180
181		caps = Blather::Stanza::Capabilities.new
182		# TODO: user a better node URI (?)
183		caps.node = 'http://catapult.sgx.soprani.ca/'
184		caps.identities = user_cap_identities()
185		caps.features = user_cap_features()
186
187		msg = caps.c
188		msg.to = p.from
189		msg.from = p.to.to_s + '/sgx'
190
191		puts 'RESPONSE6: ' + msg.inspect
192		write_to_stream msg
193	end
194
195	iq '/iq/ns:jingle', :ns => 'urn:xmpp:jingle:1' do |i, jn|
196		puts "IQj: #{i.inspect}"
197
198		if jn[0]['action'] == 'transport-accept'
199			puts "REPLY0: #{i.reply.inspect}"
200			write_to_stream i.reply
201			next
202		elsif jn[0]['action'] == 'session-terminate'
203			# TODO: unexpected (usually we do this; handle?)
204			puts "TERMINATED"
205			next
206		elsif jn[0]['action'] == 'transport-info'
207			# TODO: unexpected, but should handle in a nice way
208			puts "FAIL!!!"
209			next
210		elsif i.type == :error
211			# TODO: do something, maybe terminating the connection
212			puts 'ERROR!!!'
213			next
214		end
215
216		# TODO: should probably confirm we got session-initiate here
217
218		write_to_stream i.reply
219		puts "RESPONSE8: #{i.reply.inspect}"
220
221		msg = Blather::Stanza::Iq.new :set
222		msg.to = i.from
223		msg.from = i.to
224
225		cn = jn.children.find { |v| v.element_name == "content" }
226		puts 'CN-name: ' + cn['name']
227		puts 'JN-sid: ' + jn[0]['sid']
228
229		ibb_found = false
230		last_sid = ''
231		for child in cn.children
232			if child.element_name == 'transport'
233				puts 'TPORT: ' + child.namespace.href
234				last_sid = child['sid']
235				if 'urn:xmpp:jingle:transports:ibb:1' ==
236					child.namespace.href
237
238					ibb_found = true
239					break
240				end
241			end
242		end
243
244		j = Nokogiri::XML::Node.new 'jingle',msg.document
245		j['xmlns'] = 'urn:xmpp:jingle:1'
246		j['sid'] = jn[0]['sid']
247		msg.add_child(j)
248
249		content = Nokogiri::XML::Node.new 'content',msg.document
250		content['name'] = cn['name']
251		content['creator'] = 'initiator'
252		j.add_child(content)
253
254		transport = Nokogiri::XML::Node.new 'transport',msg.document
255		# TODO: make block-size more variable and/or dependent on sender
256		transport['block-size'] = '4096'
257		transport['xmlns'] = 'urn:xmpp:jingle:transports:ibb:1'
258		if ibb_found
259			transport['sid'] = last_sid
260			j['action'] = 'session-accept'
261			j['responder'] = i.from
262
263			dsc = Nokogiri::XML::Node.new 'description',msg.document
264			dsc['xmlns'] = 'urn:xmpp:jingle:apps:file-transfer:3'
265			content.add_child(dsc)
266		else
267			# for Conversations - it tries s5b even if caps ibb-only
268			transport['sid'] = @uuid_gen.generate
269			j['action'] = 'transport-replace'
270			j['initiator'] = i.from
271		end
272		content.add_child(transport)
273
274		@jingle_sids[transport['sid']] = jn[0]['sid']
275
276		# TODO: save <date> as well? Gajim sends, Conversations does not
277		# TODO: save/validate <size> with eventual full received length
278		fname = cn.children.find { |v| v.element_name == "description"
279			}.children.find { |w| w.element_name == "offer"
280			}.children.find { |x| x.element_name == "file"
281			}.children.find { |y| y.element_name == "name" }
282		@jingle_fnames[transport['sid']] = fname.text
283
284		puts "RESPONSE9: #{msg.inspect}"
285		write_to_stream msg
286	end
287
288	iq '/iq/ns:open', :ns =>
289		'http://jabber.org/protocol/ibb' do |i, on|
290
291		puts "IQo: #{i.inspect}"
292
293		@partial_data[on[0]['sid']] = ''
294		write_to_stream i.reply
295	end
296
297	iq '/iq/ns:data', :ns =>
298		'http://jabber.org/protocol/ibb' do |i, dn|
299
300		@partial_data[dn[0]['sid']] += Base64.decode64(dn[0].text)
301		write_to_stream i.reply
302	end
303
304	iq '/iq/ns:close', :ns =>
305		'http://jabber.org/protocol/ibb' do |i, cn|
306
307		puts "IQc: #{i.inspect}"
308		write_to_stream i.reply
309
310		# TODO: refactor below so that "message :chat?" uses same code
311		num_dest = i.to.to_s.split('@', 2)[0]
312
313		if num_dest[0] != '+'
314			# check to see if a valid shortcode context is specified
315			num_and_context = num_dest.split(';', 2)
316			if num_and_context[1] and num_and_context[1] ==
317				'phone-context=ca-us.phone-context.soprani.ca'
318
319				# TODO: check if num_dest is fully numeric
320				num_dest = num_and_context[0]
321			else
322				# TODO: text re num not (yet) supportd/implmentd
323				write_to_stream error_msg(i.reply, nil,
324					:cancel, 'item-not-found')
325				next
326			end
327		end
328
329		bare_jid = i.from.to_s.split('/', 2)[0]
330		cred_key = "catapult_cred-" + bare_jid
331
332		# TODO: connect at start of program instead
333		conn = Hiredis::Connection.new
334		conn.connect(ARGV[4], ARGV[5].to_i)
335
336		conn.write ["EXISTS", cred_key]
337		if conn.read == 0
338			conn.disconnect
339
340			# TODO: add text re credentials not being registered
341			write_to_stream error_msg(i.reply, nil, :auth,
342				'registration-required')
343			next
344		end
345
346		conn.write ["LRANGE", cred_key, 0, 3]
347		user_id, api_token, api_secret, users_num = conn.read
348		conn.disconnect
349
350		# upload cached data to server (before success reply)
351		media_name = Time.now.utc.iso8601 + '_' + @uuid_gen.generate +
352			'_' + @jingle_fnames[cn[0]['sid']]
353		puts 'name to save: ' + media_name
354
355		uri = URI.parse('https://api.catapult.inetwork.com')
356		http = Net::HTTP.new(uri.host, uri.port)
357		http.use_ssl = true
358		request = Net::HTTP::Put.new('/v1/users/' + user_id +
359			'/media/' + media_name)
360		request.basic_auth api_token, api_secret
361		request.body = @partial_data[cn[0]['sid']]
362		response = http.request(request)
363
364		puts 'eAPI response to send: ' + response.to_s + ' with code ' +
365			response.code + ', body "' + response.body + '"'
366
367		if response.code != '200'
368			# TODO: add text re unexpected code; mention code number
369			write_to_stream error_msg(i.reply, nil, :cancel,
370				'internal-server-error')
371			next
372		end
373
374		uri = URI.parse('https://api.catapult.inetwork.com')
375		http = Net::HTTP.new(uri.host, uri.port)
376		http.use_ssl = true
377		request = Net::HTTP::Post.new('/v1/users/' + user_id +
378			'/messages')
379		request.basic_auth api_token, api_secret
380		request.add_field('Content-Type', 'application/json')
381		request.body = JSON.dump({
382			'from'			=> users_num,
383			'to'			=> num_dest,
384			'text'			=> '',
385			'media'			=> [
386				'https://api.catapult.inetwork.com/v1/users/' +
387				user_id + '/media/' + media_name],
388			'tag'			=> i.id  # TODO: message has it?
389			# TODO: add back when Bandwidth AP supports it (?); now:
390			#  "The ''messages'' resource property
391			#  ''receiptRequested'' is not supported for MMS"
392			#'receiptRequested'	=> 'all',
393			#'callbackUrl'		=> ARGV[6]
394		})
395		response = http.request(request)
396
397		puts 'mAPI response to send: ' + response.to_s + ' with code ' +
398			response.code + ', body "' + response.body + '"'
399
400		if response.code != '201'
401			# TODO: add text re unexpected code; mention code number
402			write_to_stream error_msg(i.reply, nil, :cancel,
403				'internal-server-error')
404			next
405		end
406
407		@partial_data[cn[0]['sid']] = ''
408
409		# received the complete file so now close the stream
410		msg = Blather::Stanza::Iq.new :set
411		msg.to = i.from
412		msg.from = i.to
413
414		j = Nokogiri::XML::Node.new 'jingle',msg.document
415		j['xmlns'] = 'urn:xmpp:jingle:1'
416		j['action'] = 'session-terminate'
417		j['sid'] = @jingle_sids[cn[0]['sid']]
418		msg.add_child(j)
419
420		r = Nokogiri::XML::Node.new 'reason',msg.document
421		s = Nokogiri::XML::Node.new 'success',msg.document
422		r.add_child(s)
423		j.add_child(r)
424
425		puts 'RESPONSE1: ' + msg.inspect
426		write_to_stream msg
427	end
428
429	iq '/iq/ns:query', :ns =>
430		'http://jabber.org/protocol/disco#items' do |i, xpath_result|
431
432		write_to_stream i.reply
433	end
434
435	iq '/iq/ns:query', :ns =>
436		'http://jabber.org/protocol/disco#info' do |i, xpath_result|
437
438		if i.to.to_s.include? '@'
439			# TODO: confirm the node URL is expected using below
440			#puts "XR[node]: #{xpath_result[0]['node']}"
441
442			msg = i.reply
443			msg.identities = user_cap_identities()
444			msg.features = user_cap_features()
445
446			puts 'RESPONSE7: ' + msg.inspect
447			write_to_stream msg
448			next
449		end
450
451		msg = i.reply
452		msg.identities = [{:name =>
453			'Soprani.ca Gateway to XMPP - Catapult',
454			:type => 'sms-ctplt', :category => 'gateway'}]
455		msg.features = ["jabber:iq:register",
456			"jabber:iq:gateway", "jabber:iq:private",
457			"http://jabber.org/protocol/disco#info",
458			"http://jabber.org/protocol/commands",
459			"http://jabber.org/protocol/muc"]
460		write_to_stream msg
461	end
462
463	iq '/iq/ns:query', :ns => 'jabber:iq:register' do |i, qn|
464		puts "IQ: #{i.inspect}"
465
466		if i.type == :set
467			xn = qn.children.find { |v| v.element_name == "x" }
468
469			user_id = ''
470			api_token = ''
471			api_secret = ''
472			phone_num = ''
473
474			if xn.nil?
475				user_id = qn.children.find {
476					|v| v.element_name == "nick" }
477				api_token = qn.children.find {
478					|v| v.element_name == "username" }
479				api_secret = qn.children.find {
480					|v| v.element_name == "password" }
481				phone_num = qn.children.find {
482					|v| v.element_name == "phone" }
483			else
484				for field in xn.children
485					if field.element_name == "field"
486						val = field.children.find { |v|
487						v.element_name == "value" }
488
489						case field['var']
490						when 'nick'
491							user_id = val.text
492						when 'username'
493							api_token = val.text
494						when 'password'
495							api_secret = val.text
496						when 'phone'
497							phone_num = val.text
498						else
499							# TODO: error
500							puts "?: " +field['var']
501						end
502					end
503				end
504			end
505
506			if phone_num[0] != '+'
507				# TODO: add text re number not (yet) supported
508				write_to_stream error_msg(i.reply, qn, :cancel,
509					'item-not-found')
510				next
511			end
512
513			uri = URI.parse('https://api.catapult.inetwork.com')
514			http = Net::HTTP.new(uri.host, uri.port)
515			http.use_ssl = true
516			request = Net::HTTP::Get.new('/v1/users/' + user_id +
517				'/phoneNumbers/' + phone_num)
518			request.basic_auth api_token, api_secret
519			response = http.request(request)
520
521			puts 'API response: ' + response.to_s + ' with code ' +
522				response.code + ', body "' + response.body + '"'
523
524			if response.code == '200'
525				params = JSON.parse response.body
526				if params['numberState'] == 'enabled'
527					num_key = "catapult_num-" + phone_num
528
529					bare_jid = i.from.to_s.split('/', 2)[0]
530					cred_key = "catapult_cred-" + bare_jid
531
532					# TODO: pre-validate ARGV[5] is integer
533					conn = Hiredis::Connection.new
534					conn.connect(ARGV[4], ARGV[5].to_i)
535
536					# TODO: use SETNX instead
537					conn.write ["EXISTS", num_key]
538					if conn.read == 1
539						conn.disconnect
540
541						# TODO: add txt re num exists
542						write_to_stream error_msg(
543							i.reply, qn, :cancel,
544							'conflict')
545						next
546					end
547
548					conn.write ["EXISTS", cred_key]
549					if conn.read == 1
550						conn.disconnect
551
552						# TODO: add txt re already exist
553						write_to_stream error_msg(
554							i.reply, qn, :cancel,
555							'conflict')
556						next
557					end
558
559					conn.write ["SET", num_key, bare_jid]
560					if conn.read != 1
561						conn.disconnect
562
563						# TODO: catch/relay RuntimeError
564						# TODO: add txt re push failure
565						write_to_stream error_msg(
566							i.reply, qn, :cancel,
567							'internal-server-error')
568						next
569					end
570
571					conn.write ["RPUSH",cred_key,user_id]
572					conn.write ["RPUSH",cred_key,api_token]
573					conn.write ["RPUSH",cred_key,api_secret]
574					conn.write ["RPUSH",cred_key,phone_num]
575
576					# TODO: confirm cred_key list size == 4
577
578					for n in 1..4 do
579						# TODO: catch/relay RuntimeError
580						result = conn.read
581						if result != n
582							conn.disconnect
583
584							write_to_stream(
585							error_msg(
586							i.reply, qn, :cancel,
587							'internal-server-error')
588							)
589							next
590						end
591					end
592					conn.disconnect
593
594					write_to_stream i.reply
595				else
596					# TODO: add text re number disabled
597					write_to_stream error_msg(i.reply, qn,
598						:modify, 'not-acceptable')
599				end
600			elsif response.code == '401'
601				# TODO: add text re bad credentials
602				write_to_stream error_msg(i.reply, qn, :auth,
603					'not-authorized')
604			elsif response.code == '404'
605				# TODO: add text re number not found or disabled
606				write_to_stream error_msg(i.reply, qn, :cancel,
607					'item-not-found')
608			else
609				# TODO: add text re misc error, and mention code
610				write_to_stream error_msg(i.reply, qn, :modify,
611					'not-acceptable')
612			end
613
614		elsif i.type == :get
615			orig = i.reply
616
617			msg = Nokogiri::XML::Node.new 'query',orig.document
618			msg['xmlns'] = 'jabber:iq:register'
619			n1 = Nokogiri::XML::Node.new 'instructions',msg.document
620			n1.content= "Enter the information from your Account " +
621				"page as well as the Phone Number\nin your " +
622				"account you want to use (ie. '+12345678901')" +
623				".\nUser Id is nick, API Token is username, " +
624				"API Secret is password, Phone Number is phone"+
625				".\n\nThe source code for this gateway is at " +
626				"https://github.com/ossguy/sgx-catapult ." +
627				"\nCopyright (C) 2017  Denver Gingerich and " +
628				"others, licensed under AGPLv3+."
629			n2 = Nokogiri::XML::Node.new 'nick',msg.document
630			n3 = Nokogiri::XML::Node.new 'username',msg.document
631			n4 = Nokogiri::XML::Node.new 'password',msg.document
632			n5 = Nokogiri::XML::Node.new 'phone',msg.document
633			msg.add_child(n1)
634			msg.add_child(n2)
635			msg.add_child(n3)
636			msg.add_child(n4)
637			msg.add_child(n5)
638
639			x = Blather::Stanza::X.new :form, [
640				{:required => true, :type => :"text-single",
641				:label => 'User Id', :var => 'nick'},
642				{:required => true, :type => :"text-single",
643				:label => 'API Token', :var => 'username'},
644				{:required => true, :type => :"text-private",
645				:label => 'API Secret', :var => 'password'},
646				{:required => true, :type => :"text-single",
647				:label => 'Phone Number', :var => 'phone'}
648			]
649			x.title= 'Register for ' +
650				'Soprani.ca Gateway to XMPP - Catapult'
651			x.instructions= "Enter the details from your Account " +
652				"page as well as the Phone Number\nin your " +
653				"account you want to use (ie. '+12345678901')" +
654				".\n\nThe source code for this gateway is at " +
655				"https://github.com/ossguy/sgx-catapult ." +
656				"\nCopyright (C) 2017  Denver Gingerich and " +
657				"others, licensed under AGPLv3+."
658			msg.add_child(x)
659
660			orig.add_child(msg)
661			puts "RESPONSE2: #{orig.inspect}"
662			write_to_stream orig
663			puts "SENT"
664		end
665	end
666
667	subscription(:request?) do |s|
668		# TODO: are these the best to return?  really need '!' here?
669		#write_to_stream s.approve!
670		#write_to_stream s.request!
671	end
672end
673
674[:INT, :TERM].each do |sig|
675	trap(sig) {
676		puts 'Shutting down gateway...'
677		SGXcatapult.shutdown
678		puts 'Gateway has terminated.'
679
680		EM.stop
681	}
682end
683
684class ReceiptMessage < Blather::Stanza
685	def self.new(to = nil)
686		node = super :message
687		node.to = to
688		node
689	end
690end
691
692class WebhookHandler < Goliath::API
693	def send_media(from, to, media_url)
694		# we assume media_url is of the form (always the case so far):
695		#  https://api.catapult.inetwork.com/v1/users/[uid]/media/[file]
696
697		# the caller must guarantee that 'to' is a bare JID
698		proxy_url = ARGV[8] + to + '/' + media_url.split('/', 8)[7]
699
700		puts 'ORIG_URL: ' + media_url
701		puts 'PROX_URL: ' + proxy_url
702
703		# put URL in the body (so Conversations will still see it)...
704		msg = Blather::Stanza::Message.new(to, proxy_url)
705		msg.from = from
706
707		# ...but also provide URL in XEP-0066 (OOB) fashion
708		# TODO: confirm client supports OOB or don't send this
709		x = Nokogiri::XML::Node.new 'x', msg.document
710		x['xmlns'] = 'jabber:x:oob'
711
712		urln = Nokogiri::XML::Node.new 'url', msg.document
713		urlc = Nokogiri::XML::Text.new proxy_url, msg.document
714
715		urln.add_child(urlc)
716		x.add_child(urln)
717		msg.add_child(x)
718
719		SGXcatapult.write(msg)
720	end
721
722	def response(env)
723		puts 'ENV: ' + env.to_s
724		body = Rack::Request.new(env).body.read
725		puts 'BODY: ' + body
726		params = JSON.parse body
727
728		users_num = ''
729		others_num = ''
730		if params['direction'] == 'in'
731			users_num = params['to']
732			others_num = params['from']
733		elsif params['direction'] == 'out'
734			users_num = params['from']
735			others_num = params['to']
736		else
737			# TODO: exception or similar
738			puts "big problem: '" + params['direction'] + "'"
739			return [200, {}, "OK"]
740		end
741
742		num_key = "catapult_num-" + users_num
743
744		if others_num[0] != '+'
745			# TODO: check that others_num actually a shortcode first
746			others_num = others_num +
747				';phone-context=ca-us.phone-context.soprani.ca'
748		end
749
750		conn = Hiredis::Connection.new
751		conn.connect(ARGV[4], ARGV[5].to_i)
752
753		conn.write ["EXISTS", num_key]
754		if conn.read == 0
755			conn.disconnect
756
757			puts "num_key (#{num_key}) DNE; Catapult misconfigured?"
758
759			# TODO: likely not appropriate; give error to Catapult?
760			# TODO: add text re credentials not being registered
761			#write_to_stream error_msg(m.reply, m.body, :auth,
762			#	'registration-required')
763			return [200, {}, "OK"]
764		end
765
766		conn.write ["GET", num_key]
767		bare_jid = conn.read
768		conn.disconnect
769
770		msg = ''
771		case params['direction']
772		when 'in'
773			text = ''
774			case params['eventType']
775			when 'sms'
776				text = params['text']
777			when 'mms'
778				has_media = false
779				params['media'].each do |media_url|
780					if not media_url.end_with?('.smil',
781						'.txt', '.xml')
782
783						has_media = true
784						send_media(others_num + '@' +
785							ARGV[0],
786							bare_jid, media_url)
787					end
788				end
789
790				if params['text'].empty?
791					if not has_media
792						text = '[suspected group msg ' +
793							'with no text (odd)]'
794					end
795				else
796					if has_media
797						# TODO: write/use a caption XEP
798						text = params['text']
799					else
800						text = '[suspected group msg ' +
801							'(recipient list not ' +
802							'available) with ' +
803							'following text] ' +
804							params['text']
805					end
806				end
807
808				# ie. if text param non-empty or had no media
809				if not text.empty?
810					msg = Blather::Stanza::Message.new(
811						bare_jid, text)
812					msg.from = others_num + '@' + ARGV[0]
813					SGXcatapult.write(msg)
814				end
815
816				return [200, {}, "OK"]
817			else
818				text = "unknown type (#{params['eventType']})" +
819					" with text: " + params['text']
820
821				# TODO log/notify of this properly
822				puts text
823			end
824
825			msg = Blather::Stanza::Message.new(bare_jid, text)
826		else # per prior switch, this is:  params['direction'] == 'out'
827			msg = ReceiptMessage.new(bare_jid)
828
829			# TODO: put in member/instance variable
830			uuid_gen = UUID.new
831			msg['id'] = uuid_gen.generate
832
833			case params['deliveryState']
834			when 'not-delivered'
835				# TODO: add text re deliveryDescription reason
836				msg = SGXcatapult.error_msg(msg, nil, :cancel,
837					'service-unavailable')
838				return [200, {}, "OK"]
839			when 'delivered'
840				# TODO: send only when requested per XEP-0184
841				rcvd = Nokogiri::XML::Node.new 'received',
842					msg.document
843				rcvd['xmlns'] = 'urn:xmpp:receipts'
844				rcvd['id'] = params['tag']
845				msg.add_child(rcvd)
846			when 'waiting'
847				# can't really do anything with it; nice to know
848				puts "message with id #{params['tag']} waiting"
849				return [200, {}, "OK"]
850			else
851				# TODO: notify somehow of unknown state receivd?
852				puts "message with id #{params['tag']} has " +
853					"other state #{params['deliveryState']}"
854				return [200, {}, "OK"]
855			end
856
857			puts "RESPONSE4: #{msg.inspect}"
858		end
859
860		msg.from = others_num + '@' + ARGV[0]
861		SGXcatapult.write(msg)
862
863		[200, {}, "OK"]
864	end
865end
866
867EM.run do
868	SGXcatapult.run
869
870	server = Goliath::Server.new('0.0.0.0', ARGV[7].to_i)
871	server.api = WebhookHandler.new
872	server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
873	server.logger = Log4r::Logger.new('goliath')
874	server.logger.add(Log4r::StdoutOutputter.new('console'))
875	server.logger.level = Log4r::INFO
876	server.start
877end