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