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