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