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