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