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