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