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