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', 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		conn.write ["GET", jid_key]
 576		existing_jid = conn.read
 577
 578		if not existing_jid.nil? and existing_jid != bare_jid
 579			conn.disconnect
 580
 581			# TODO: add/log text re credentials exist already
 582			write_to_stream error_msg(
 583				i.reply, qn, :cancel,
 584				'conflict')
 585			return false
 586		end
 587
 588		# ASSERT: existing_jid is nil or equal to bare_jid
 589
 590		conn.write ["EXISTS", cred_key]
 591		creds_exist = conn.read
 592		if 1 == creds_exist
 593			conn.write ["LRANGE", cred_key, 0, 3]
 594			if [user_id, api_token, api_secret, phone_num] !=
 595				conn.read
 596
 597				conn.disconnect
 598
 599				# TODO: add/log txt re credentials exist already
 600				write_to_stream error_msg(
 601					i.reply, qn, :cancel,
 602					'conflict')
 603				return false
 604			end
 605		end
 606
 607		# ASSERT: cred_key does not exist or its value equals input vals
 608
 609		# not necessary if existing_jid non-nil, but easier to do anyway
 610		conn.write ["SET", jid_key, bare_jid]
 611		if conn.read != 'OK'
 612			conn.disconnect
 613
 614			# TODO: catch/relay RuntimeError
 615			# TODO: add txt re push failure
 616			write_to_stream error_msg(
 617				i.reply, qn, :cancel,
 618				'internal-server-error')
 619			return false
 620		end
 621
 622		if 1 == creds_exist
 623			# per above ASSERT, cred_key value equals input already
 624			conn.disconnect
 625			write_to_stream i.reply
 626			return true
 627		end
 628
 629		conn.write ["RPUSH", cred_key, user_id]
 630		conn.write ["RPUSH", cred_key, api_token]
 631		conn.write ["RPUSH", cred_key, api_secret]
 632		conn.write ["RPUSH", cred_key, phone_num]
 633
 634		# TODO: confirm cred_key list size == 4
 635
 636		(1..4).each do |n|
 637			# TODO: catch/relay RuntimeError
 638			result = conn.read
 639			if result != n
 640				conn.disconnect
 641
 642				write_to_stream error_msg(
 643					i.reply, qn, :cancel,
 644					'internal-server-error')
 645				return false
 646			end
 647		end
 648		conn.disconnect
 649
 650		write_to_stream i.reply
 651
 652		return true
 653	end
 654
 655	iq '/iq/ns:query', ns: 'jabber:iq:register' do |i, qn|
 656		puts "IQ: #{i.inspect}"
 657
 658		if i.type == :set
 659			xn = qn.children.find { |v| v.element_name == "x" }
 660
 661			user_id = ''
 662			api_token = ''
 663			api_secret = ''
 664			phone_num = ''
 665
 666			if xn.nil?
 667				user_id = qn.children.find { |v|
 668					v.element_name == "nick"
 669				}
 670				api_token = qn.children.find { |v|
 671					v.element_name == "username"
 672				}
 673				api_secret = qn.children.find { |v|
 674					v.element_name == "password"
 675				}
 676				phone_num = qn.children.find { |v|
 677					v.element_name == "phone"
 678				}
 679			else
 680				xn.children.each do |field|
 681					if field.element_name == "field"
 682						val = field.children.find { |v|
 683							v.element_name == "value"
 684						}
 685
 686						case field['var']
 687						when 'nick'
 688							user_id = val.text
 689						when 'username'
 690							api_token = val.text
 691						when 'password'
 692							api_secret = val.text
 693						when 'phone'
 694							phone_num = val.text
 695						else
 696							# TODO: error
 697							puts "?: " +field['var']
 698						end
 699					end
 700				end
 701			end
 702
 703			if phone_num[0] != '+'
 704				# TODO: add text re number not (yet) supported
 705				write_to_stream error_msg(
 706					i.reply, qn, :cancel,
 707					'item-not-found'
 708				)
 709				next
 710			end
 711
 712			uri = URI.parse('https://api.catapult.inetwork.com')
 713			http = Net::HTTP.new(uri.host, uri.port)
 714			http.use_ssl = true
 715			request = Net::HTTP::Get.new('/v1/users/' + user_id +
 716				'/phoneNumbers/' + phone_num)
 717			request.basic_auth api_token, api_secret
 718			response = http.request(request)
 719
 720			puts 'API response: ' + response.to_s + ' with code ' +
 721				response.code + ', body "' + response.body + '"'
 722
 723			if response.code == '200'
 724				params = JSON.parse response.body
 725				if params['numberState'] == 'enabled'
 726					if not check_then_register(
 727						user_id, api_token, api_secret,
 728						phone_num, i, qn
 729					)
 730						next
 731					end
 732				else
 733					# TODO: add text re number disabled
 734					write_to_stream error_msg(
 735						i.reply, qn,
 736						:modify, 'not-acceptable'
 737					)
 738				end
 739			elsif response.code == '401'
 740				# TODO: add text re bad credentials
 741				write_to_stream error_msg(
 742					i.reply, qn, :auth,
 743					'not-authorized'
 744				)
 745			elsif response.code == '404'
 746				# TODO: add text re number not found or disabled
 747				write_to_stream error_msg(
 748					i.reply, qn, :cancel,
 749					'item-not-found'
 750				)
 751			else
 752				# TODO: add text re misc error, and mention code
 753				write_to_stream error_msg(
 754					i.reply, qn, :modify,
 755					'not-acceptable'
 756				)
 757			end
 758
 759		elsif i.type == :get
 760			orig = i.reply
 761
 762			bare_jid = i.from.to_s.split('/', 2)[0]
 763			cred_key = "catapult_cred-" + bare_jid
 764
 765			conn = Hiredis::Connection.new
 766			conn.connect(ARGV[4], ARGV[5].to_i)
 767			conn.write(["LINDEX", cred_key, 3])
 768			existing_number = conn.read
 769			conn.disconnect
 770
 771			msg = Nokogiri::XML::Node.new 'query', orig.document
 772			msg['xmlns'] = 'jabber:iq:register'
 773
 774			if existing_number
 775				msg.add_child(
 776					Nokogiri::XML::Node.new('registered', msg.document)
 777				)
 778			end
 779
 780			n1 = Nokogiri::XML::Node.new 'instructions', msg.document
 781			n1.content= "Enter the information from your Account "\
 782				"page as well as the Phone Number\nin your "\
 783				"account you want to use (ie. '+12345678901')"\
 784				".\nUser Id is nick, API Token is username, "\
 785				"API Secret is password, Phone Number is phone"\
 786				".\n\nThe source code for this gateway is at "\
 787				"https://gitlab.com/ossguy/sgx-catapult ."\
 788				"\nCopyright (C) 2017  Denver Gingerich and "\
 789				"others, licensed under AGPLv3+."
 790			n2 = Nokogiri::XML::Node.new 'nick', msg.document
 791			n3 = Nokogiri::XML::Node.new 'username', msg.document
 792			n4 = Nokogiri::XML::Node.new 'password', msg.document
 793			n5 = Nokogiri::XML::Node.new 'phone', msg.document
 794			n5.content = existing_number.to_s
 795			msg.add_child(n1)
 796			msg.add_child(n2)
 797			msg.add_child(n3)
 798			msg.add_child(n4)
 799			msg.add_child(n5)
 800
 801			x = Blather::Stanza::X.new :form, [
 802				{
 803					required: true, type: :"text-single",
 804					label: 'User Id', var: 'nick'
 805				},
 806				{
 807					required: true, type: :"text-single",
 808					label: 'API Token', var: 'username'
 809				},
 810				{
 811					required: true, type: :"text-private",
 812					label: 'API Secret', var: 'password'
 813				},
 814				{
 815					required: true, type: :"text-single",
 816					label: 'Phone Number', var: 'phone',
 817					value: existing_number.to_s
 818				}
 819			]
 820			x.title= 'Register for '\
 821				'Soprani.ca Gateway to XMPP - Catapult'
 822			x.instructions= "Enter the details from your Account "\
 823				"page as well as the Phone Number\nin your "\
 824				"account you want to use (ie. '+12345678901')"\
 825				".\n\nThe source code for this gateway is at "\
 826				"https://gitlab.com/ossguy/sgx-catapult ."\
 827				"\nCopyright (C) 2017  Denver Gingerich and "\
 828				"others, licensed under AGPLv3+."
 829			msg.add_child(x)
 830
 831			orig.add_child(msg)
 832			puts "RESPONSE2: #{orig.inspect}"
 833			write_to_stream orig
 834			puts "SENT"
 835		end
 836	end
 837
 838	subscription(:request?) do |s|
 839		# TODO: are these the best to return?  really need '!' here?
 840		#write_to_stream s.approve!
 841		#write_to_stream s.request!
 842	end
 843end
 844
 845[:INT, :TERM].each do |sig|
 846	trap(sig) {
 847		puts 'Shutting down gateway...'
 848		SGXcatapult.shutdown
 849		puts 'Gateway has terminated.'
 850
 851		EM.stop
 852	}
 853end
 854
 855class ReceiptMessage < Blather::Stanza
 856	def self.new(to = nil)
 857		node = super :message
 858		node.to = to
 859		node
 860	end
 861end
 862
 863class WebhookHandler < Goliath::API
 864	def send_media(from, to, media_url)
 865		# we assume media_url is of the form (always the case so far):
 866		#  https://api.catapult.inetwork.com/v1/users/[uid]/media/[file]
 867
 868		# the caller must guarantee that 'to' is a bare JID
 869		proxy_url = ARGV[8] + to + '/' + media_url.split('/', 8)[7]
 870
 871		puts 'ORIG_URL: ' + media_url
 872		puts 'PROX_URL: ' + proxy_url
 873
 874		# put URL in the body (so Conversations will still see it)...
 875		msg = Blather::Stanza::Message.new(to, proxy_url)
 876		msg.from = from
 877
 878		# ...but also provide URL in XEP-0066 (OOB) fashion
 879		# TODO: confirm client supports OOB or don't send this
 880		x = Nokogiri::XML::Node.new 'x', msg.document
 881		x['xmlns'] = 'jabber:x:oob'
 882
 883		urln = Nokogiri::XML::Node.new 'url', msg.document
 884		urlc = Nokogiri::XML::Text.new proxy_url, msg.document
 885
 886		urln.add_child(urlc)
 887		x.add_child(urln)
 888		msg.add_child(x)
 889
 890		SGXcatapult.write(msg)
 891	end
 892
 893	def response(env)
 894		puts 'ENV: ' + env.to_s
 895		body = Rack::Request.new(env).body.read
 896		puts 'BODY: ' + body
 897		params = JSON.parse body
 898
 899		users_num = ''
 900		others_num = ''
 901		if params['direction'] == 'in'
 902			users_num = params['to']
 903			others_num = params['from']
 904		elsif params['direction'] == 'out'
 905			users_num = params['from']
 906			others_num = params['to']
 907		else
 908			# TODO: exception or similar
 909			puts "big problem: '" + params['direction'] + "'"
 910			return [200, {}, "OK"]
 911		end
 912
 913		jid_key = "catapult_jid-" + users_num
 914
 915		if others_num[0] != '+'
 916			# TODO: check that others_num actually a shortcode first
 917			others_num +=
 918				';phone-context=ca-us.phone-context.soprani.ca'
 919		end
 920
 921		conn = Hiredis::Connection.new
 922		conn.connect(ARGV[4], ARGV[5].to_i)
 923
 924		conn.write ["EXISTS", jid_key]
 925		if conn.read == 0
 926			conn.disconnect
 927
 928			puts "jid_key (#{jid_key}) DNE; Catapult misconfigured?"
 929
 930			# TODO: likely not appropriate; give error to Catapult?
 931			# TODO: add text re credentials not being registered
 932			#write_to_stream error_msg(m.reply, m.body, :auth,
 933			#	'registration-required')
 934			return [200, {}, "OK"]
 935		end
 936
 937		conn.write ["GET", jid_key]
 938		bare_jid = conn.read
 939		conn.disconnect
 940
 941		msg = ''
 942		case params['direction']
 943		when 'in'
 944			text = ''
 945			case params['eventType']
 946			when 'sms'
 947				text = params['text']
 948			when 'mms'
 949				has_media = false
 950				params['media'].each do |media_url|
 951					if not media_url.end_with?(
 952						'.smil', '.txt', '.xml'
 953					)
 954
 955						has_media = true
 956						send_media(
 957							others_num + '@' +
 958							ARGV[0],
 959							bare_jid, media_url
 960						)
 961					end
 962				end
 963
 964				if params['text'].empty?
 965					if not has_media
 966						text = '[suspected group msg '\
 967							'with no text (odd)]'
 968					end
 969				else
 970					text = if has_media
 971						# TODO: write/use a caption XEP
 972						params['text']
 973					else
 974						'[suspected group msg '\
 975						'(recipient list not '\
 976						'available) with '\
 977						'following text] ' +
 978						params['text']
 979					end
 980				end
 981
 982				# ie. if text param non-empty or had no media
 983				if not text.empty?
 984					msg = Blather::Stanza::Message.new(
 985						bare_jid, text)
 986					msg.from = others_num + '@' + ARGV[0]
 987					SGXcatapult.write(msg)
 988				end
 989
 990				return [200, {}, "OK"]
 991			else
 992				text = "unknown type (#{params['eventType']})"\
 993					" with text: " + params['text']
 994
 995				# TODO: log/notify of this properly
 996				puts text
 997			end
 998
 999			msg = Blather::Stanza::Message.new(bare_jid, text)
1000		else # per prior switch, this is:  params['direction'] == 'out'
1001			tag_parts = params['tag'].split(' ', 2)
1002			id = WEBrick::HTTPUtils.unescape(tag_parts[0])
1003			resourcepart = WEBrick::HTTPUtils.unescape(tag_parts[1])
1004
1005			case params['deliveryState']
1006			when 'not-delivered'
1007				# create a bare message like the one user sent
1008				msg = Blather::Stanza::Message.new(
1009					others_num + '@' + ARGV[0])
1010				msg.from = bare_jid + '/' + resourcepart
1011				msg['id'] = id
1012
1013				# create an error reply to the bare message
1014				msg = Blather::StanzaError.new(
1015					msg,
1016					'recipient-unavailable',
1017					:wait
1018				).to_node
1019			when 'delivered'
1020				msg = ReceiptMessage.new(bare_jid)
1021
1022				# TODO: put in member/instance variable
1023				uuid_gen = UUID.new
1024				msg['id'] = uuid_gen.generate
1025
1026				# TODO: send only when requested per XEP-0184
1027				rcvd = Nokogiri::XML::Node.new(
1028					'received',
1029					msg.document
1030				)
1031				rcvd['xmlns'] = 'urn:xmpp:receipts'
1032				rcvd['id'] = id
1033				msg.add_child(rcvd)
1034			when 'waiting'
1035				# can't really do anything with it; nice to know
1036				puts "message with id #{id} waiting"
1037				return [200, {}, "OK"]
1038			else
1039				# TODO: notify somehow of unknown state receivd?
1040				puts "message with id #{id} has "\
1041					"other state #{params['deliveryState']}"
1042				return [200, {}, "OK"]
1043			end
1044
1045			puts "RESPONSE4: #{msg.inspect}"
1046		end
1047
1048		msg.from = others_num + '@' + ARGV[0]
1049		SGXcatapult.write(msg)
1050
1051		[200, {}, "OK"]
1052	end
1053end
1054
1055EM.run do
1056	SGXcatapult.run
1057
1058	# required when using Prosody otherwise disconnects on 6-hour inactivity
1059	EM.add_periodic_timer(3600) do
1060		msg = Blather::Stanza::Iq::Ping.new(:get, 'localhost')
1061		msg.from = ARGV[0]
1062		SGXcatapult.write(msg)
1063	end
1064
1065	server = Goliath::Server.new('0.0.0.0', ARGV[7].to_i)
1066	server.api = WebhookHandler.new
1067	server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
1068	server.logger = Log4r::Logger.new('goliath')
1069	server.logger.add(Log4r::StdoutOutputter.new('console'))
1070	server.logger.level = Log4r::INFO
1071	server.start
1072end