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