sgx-catapult.rb

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