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