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