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