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