sgx-catapult.rb

   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.identities = user_cap_identities
 667			msg.features = user_cap_features
 668
 669			puts 'RESPONSE7: ' + msg.inspect
 670			write_to_stream msg
 671			next
 672		end
 673
 674		# respond to capabilities request for sgx-catapult itself
 675		msg = i.reply
 676		msg.identities = [{
 677			name: 'Soprani.ca Gateway to XMPP - Catapult',
 678			type: 'sms', category: 'gateway'
 679		}]
 680		msg.features = @gateway_features
 681		write_to_stream msg
 682	end
 683
 684	def self.check_then_register(i, *creds)
 685		jid_key = "catapult_jid-#{creds.last}"
 686		bare_jid = i.from.stripped
 687		cred_key = "catapult_cred-#{bare_jid}"
 688
 689		REDIS.get(jid_key).then { |existing_jid|
 690			if existing_jid && existing_jid != bare_jid
 691				# TODO: add/log text: credentials exist already
 692				EMPromise.reject([:cancel, 'conflict'])
 693			end
 694		}.then {
 695			REDIS.lrange(cred_key, 0, 3)
 696		}.then { |existing_creds|
 697			# TODO: add/log text: credentials exist already
 698			if existing_creds.length == 4 && creds != existing_creds
 699				EMPromise.reject([:cancel, 'conflict'])
 700			elsif existing_creds.length < 4
 701				REDIS.rpush(cred_key, *creds).then { |length|
 702					if length != 4
 703						EMPromise.reject([
 704							:cancel,
 705							'internal-server-error'
 706						])
 707					end
 708				}
 709			end
 710		}.then {
 711			# not necessary if existing_jid non-nil, easier this way
 712			REDIS.set(jid_key, bare_jid)
 713		}.then { |result|
 714			if result != 'OK'
 715				# TODO: add txt re push failure
 716				EMPromise.reject(
 717					[:cancel, 'internal-server-error']
 718				)
 719			end
 720		}.then {
 721			write_to_stream i.reply
 722		}
 723	end
 724
 725	def self.creds_from_registration_query(qn)
 726		xn = qn.children.find { |v| v.element_name == "x" }
 727
 728		if xn
 729			xn.children.each_with_object({}) do |field, h|
 730				next if field.element_name != "field"
 731				val = field.children.find { |v|
 732					v.element_name == "value"
 733				}
 734
 735				case field['var']
 736				when 'nick'
 737					h[:user_id] = val.text
 738				when 'username'
 739					h[:api_token] = val.text
 740				when 'password'
 741					h[:api_secret] = val.text
 742				when 'phone'
 743					h[:phone_num] = val.text
 744				else
 745					# TODO: error
 746					puts "?: #{field['var']}"
 747				end
 748			end
 749		else
 750			qn.children.each_with_object({}) do |field, h|
 751				case field.element_name
 752				when "nick"
 753					h[:user_id] = field.text
 754				when "username"
 755					h[:api_token] = field.text
 756				when "password"
 757					h[:api_secret] = field.text
 758				when "phone"
 759					h[:phone_num] = field.text
 760				end
 761			end
 762		end.values_at(:user_id, :api_token, :api_secret, :phone_num)
 763	end
 764
 765	def self.process_registration(i, qn)
 766		EMPromise.resolve(
 767			qn.children.find { |v| v.element_name == "remove" }
 768		).then { |rn|
 769			if rn
 770				puts "received <remove/> - ignoring for now..."
 771				EMPromise.reject(:done)
 772			else
 773				creds_from_registration_query(qn)
 774			end
 775		}.then { |user_id, api_token, api_secret, phone_num|
 776			if phone_num[0] == '+'
 777				[user_id, api_token, api_secret, phone_num]
 778			else
 779				# TODO: add text re number not (yet) supported
 780				EMPromise.reject([:cancel, 'item-not-found'])
 781			end
 782		}.then { |user_id, api_token, api_secret, phone_num|
 783			call_catapult(
 784				api_token,
 785				api_secret,
 786				:get,
 787				"v1/users/#{user_id}/phoneNumbers/#{phone_num}"
 788			).then { |response|
 789				params = JSON.parse(response)
 790				if params['numberState'] == 'enabled'
 791					check_then_register(
 792						i,
 793						user_id,
 794						api_token,
 795						api_secret,
 796						phone_num
 797					)
 798				else
 799					# TODO: add text re number disabled
 800					EMPromise.reject([:modify, 'not-acceptable'])
 801				end
 802			}
 803		}.catch { |e|
 804			EMPromise.reject(case e
 805			when 401
 806				# TODO: add text re bad credentials
 807				[:auth, 'not-authorized']
 808			when 404
 809				# TODO: add text re number not found or disabled
 810				[:cancel, 'item-not-found']
 811			when Integer
 812				[:modify, 'not-acceptable']
 813			else
 814				e
 815			end)
 816		}
 817	end
 818
 819	def self.registration_form(orig, existing_number=nil)
 820		msg = Nokogiri::XML::Node.new 'query', orig.document
 821		msg['xmlns'] = 'jabber:iq:register'
 822
 823		if existing_number
 824			msg.add_child(
 825				Nokogiri::XML::Node.new(
 826					'registered', msg.document
 827				)
 828			)
 829		end
 830
 831		n1 = Nokogiri::XML::Node.new(
 832			'instructions', msg.document
 833		)
 834		n1.content = "Enter the information from your Account "\
 835			"page as well as the Phone Number\nin your "\
 836			"account you want to use (ie. '+12345678901')"\
 837			".\nUser Id is nick, API Token is username, "\
 838			"API Secret is password, Phone Number is phone"\
 839			".\n\nThe source code for this gateway is at "\
 840			"https://gitlab.com/ossguy/sgx-catapult ."\
 841			"\nCopyright (C) 2017-2020  Denver Gingerich "\
 842			"and others, licensed under AGPLv3+."
 843		n2 = Nokogiri::XML::Node.new 'nick', msg.document
 844		n3 = Nokogiri::XML::Node.new 'username', msg.document
 845		n4 = Nokogiri::XML::Node.new 'password', msg.document
 846		n5 = Nokogiri::XML::Node.new 'phone', msg.document
 847		n5.content = existing_number.to_s
 848		msg.add_child(n1)
 849		msg.add_child(n2)
 850		msg.add_child(n3)
 851		msg.add_child(n4)
 852		msg.add_child(n5)
 853
 854		x = Blather::Stanza::X.new :form, [
 855			{
 856				required: true, type: :"text-single",
 857				label: 'User Id', var: 'nick'
 858			},
 859			{
 860				required: true, type: :"text-single",
 861				label: 'API Token', var: 'username'
 862			},
 863			{
 864				required: true, type: :"text-private",
 865				label: 'API Secret', var: 'password'
 866			},
 867			{
 868				required: true, type: :"text-single",
 869				label: 'Phone Number', var: 'phone',
 870				value: existing_number.to_s
 871			}
 872		]
 873		x.title = 'Register for '\
 874			'Soprani.ca Gateway to XMPP - Catapult'
 875		x.instructions = "Enter the details from your Account "\
 876			"page as well as the Phone Number\nin your "\
 877			"account you want to use (ie. '+12345678901')"\
 878			".\n\nThe source code for this gateway is at "\
 879			"https://gitlab.com/ossguy/sgx-catapult ."\
 880			"\nCopyright (C) 2017-2020  Denver Gingerich "\
 881			"and others, licensed under AGPLv3+."
 882		msg.add_child(x)
 883
 884		orig.add_child(msg)
 885
 886		return orig
 887	end
 888
 889	iq '/iq/ns:query', ns: 'jabber:iq:register' do |i, qn|
 890		puts "IQ: #{i.inspect}"
 891
 892		case i.type
 893		when :set
 894			process_registration(i, qn)
 895		when :get
 896			bare_jid = i.from.stripped
 897			cred_key = "catapult_cred-#{bare_jid}"
 898			REDIS.lindex(cred_key, 3).then { |existing_number|
 899				reply = registration_form(i.reply, existing_number)
 900				puts "RESPONSE2: #{reply.inspect}"
 901				write_to_stream reply
 902			}
 903		else
 904			# Unknown IQ, ignore for now
 905			EMPromise.reject(:done)
 906		end.catch { |e|
 907			if e.is_a?(Array) && e.length == 2
 908				write_to_stream error_msg(i.reply, qn, *e)
 909			elsif e != :done
 910				EMPromise.reject(e)
 911			end
 912		}.catch(&method(:panic))
 913	end
 914
 915	iq :get? do |i|
 916		write_to_stream error_msg(i.reply, i.children, 'cancel', 'feature-not-implemented')
 917	end
 918
 919	iq :set? do |i|
 920		write_to_stream error_msg(i.reply, i.children, 'cancel', 'feature-not-implemented')
 921	end
 922end
 923
 924class ReceiptMessage < Blather::Stanza
 925	def self.new(to=nil)
 926		node = super :message
 927		node.to = to
 928		node
 929	end
 930end
 931
 932class WebhookHandler < Goliath::API
 933	use Goliath::Rack::Params
 934
 935	def response(env)
 936		puts 'ENV: ' + env.reject{ |k| k == 'params' }.to_s
 937
 938		users_num = ''
 939		others_num = ''
 940		if params['direction'] == 'in'
 941			users_num = params['to']
 942			others_num = params['from']
 943		elsif params['direction'] == 'out'
 944			users_num = params['from']
 945			others_num = params['to']
 946		else
 947			# TODO: exception or similar
 948			puts "big problem: '" + params['direction'] + "'" + body
 949			return [200, {}, "OK"]
 950		end
 951
 952		puts 'BODY - messageId: ' + params['messageId'] +
 953			', eventType: ' + params['eventType'] +
 954			', time: ' + params['time'] +
 955			', direction: ' + params['direction'] +
 956			', state: ' + params['state'] +
 957			', deliveryState: ' + (params['deliveryState'] ?
 958				params['deliveryState'] : 'NONE') +
 959			', deliveryCode: ' + (params['deliveryCode'] ?
 960				params['deliveryCode'] : 'NONE') +
 961			', deliveryDesc: ' + (params['deliveryDescription'] ?
 962				params['deliveryDescription'] : 'NONE') +
 963			', tag: ' + (params['tag'] ? params['tag'] : 'NONE') +
 964			', media: ' + (params['media'] ? params['media'].to_s :
 965				'NONE')
 966
 967		if others_num[0] != '+'
 968			# TODO: check that others_num actually a shortcode first
 969			others_num +=
 970				';phone-context=ca-us.phone-context.soprani.ca'
 971		end
 972
 973		jid_key = "catapult_jid-#{users_num}"
 974		bare_jid = REDIS.get(jid_key).promise.sync
 975
 976		if !bare_jid
 977			puts "jid_key (#{jid_key}) DNE; Catapult misconfigured?"
 978
 979			# TODO: likely not appropriate; give error to Catapult?
 980			# TODO: add text re credentials not being registered
 981			#write_to_stream error_msg(m.reply, m.body, :auth,
 982			#	'registration-required')
 983			return [200, {}, "OK"]
 984		end
 985
 986		msg = ''
 987		case params['direction']
 988		when 'in'
 989			text = ''
 990			case params['eventType']
 991			when 'sms'
 992				text = params['text']
 993			when 'mms'
 994				has_media = false
 995				params['media'].each do |media_url|
 996					if not media_url.end_with?(
 997						'.smil', '.txt', '.xml'
 998					)
 999
1000						has_media = true
1001						SGXcatapult.send_media(
1002							others_num + '@' +
1003							ARGV[0],
1004							bare_jid, media_url
1005						)
1006					end
1007				end
1008
1009				if params['text'].empty?
1010					if not has_media
1011						text = '[suspected group msg '\
1012							'with no text (odd)]'
1013					end
1014				else
1015					text = if has_media
1016						# TODO: write/use a caption XEP
1017						params['text']
1018					else
1019						'[suspected group msg '\
1020						'(recipient list not '\
1021						'available) with '\
1022						'following text] ' +
1023						params['text']
1024					end
1025				end
1026
1027				# ie. if text param non-empty or had no media
1028				if not text.empty?
1029					msg = Blather::Stanza::Message.new(
1030						bare_jid, text)
1031					msg.from = others_num + '@' + ARGV[0]
1032					SGXcatapult.write(msg)
1033				end
1034
1035				return [200, {}, "OK"]
1036			else
1037				text = "unknown type (#{params['eventType']})"\
1038					" with text: " + params['text']
1039
1040				# TODO: log/notify of this properly
1041				puts text
1042			end
1043
1044			msg = Blather::Stanza::Message.new(bare_jid, text)
1045		else # per prior switch, this is:  params['direction'] == 'out'
1046			tag_parts = params['tag'].split(/ /, 2)
1047			id = WEBrick::HTTPUtils.unescape(tag_parts[0])
1048			resourcepart = WEBrick::HTTPUtils.unescape(tag_parts[1])
1049
1050			case params['deliveryState']
1051			when 'not-delivered'
1052				# create a bare message like the one user sent
1053				msg = Blather::Stanza::Message.new(
1054					others_num + '@' + ARGV[0])
1055				msg.from = bare_jid + '/' + resourcepart
1056				msg['id'] = id
1057
1058				# create an error reply to the bare message
1059				msg = Blather::StanzaError.new(
1060					msg,
1061					'recipient-unavailable',
1062					:wait
1063				).to_node
1064			when 'delivered'
1065				msg = ReceiptMessage.new(bare_jid)
1066
1067				# TODO: put in member/instance variable
1068				msg['id'] = SecureRandom.uuid
1069
1070				# TODO: send only when requested per XEP-0184
1071				rcvd = Nokogiri::XML::Node.new(
1072					'received',
1073					msg.document
1074				)
1075				rcvd['xmlns'] = 'urn:xmpp:receipts'
1076				rcvd['id'] = id
1077				msg.add_child(rcvd)
1078			when 'waiting'
1079				# can't really do anything with it; nice to know
1080				puts "message with id #{id} waiting"
1081				return [200, {}, "OK"]
1082			else
1083				# TODO: notify somehow of unknown state receivd?
1084				puts "message with id #{id} has "\
1085					"other state #{params['deliveryState']}"
1086				return [200, {}, "OK"]
1087			end
1088
1089			puts "RESPONSE4: #{msg.inspect}"
1090		end
1091
1092		msg.from = others_num + '@' + ARGV[0]
1093		SGXcatapult.write(msg)
1094
1095		[200, {}, "OK"]
1096
1097	rescue Exception => e
1098		puts 'Shutting down gateway due to exception 013: ' + e.message
1099		SGXcatapult.shutdown
1100		puts 'Gateway has terminated.'
1101		EM.stop
1102	end
1103end
1104
1105at_exit do
1106	$stdout.sync = true
1107
1108	puts "Soprani.ca/SMS Gateway for XMPP - Catapult\n"\
1109		"==>> last commit of this version is " + `git rev-parse HEAD` + "\n"
1110
1111	if ARGV.size != 7
1112		puts "Usage: sgx-catapult.rb <component_jid> "\
1113			"<component_password> <server_hostname> "\
1114			"<server_port> <delivery_receipt_url> "\
1115			"<http_listen_port> <mms_proxy_prefix_url>"
1116		exit 0
1117	end
1118
1119	t = Time.now
1120	puts "LOG %d.%09d: starting...\n\n" % [t.to_i, t.nsec]
1121
1122	EM.run do
1123		REDIS = EM::Hiredis.connect
1124
1125		SGXcatapult.run
1126
1127		# required when using Prosody otherwise disconnects on 6-hour inactivity
1128		EM.add_periodic_timer(3600) do
1129			msg = Blather::Stanza::Iq::Ping.new(:get, 'localhost')
1130			msg.from = ARGV[0]
1131			SGXcatapult.write(msg)
1132		end
1133
1134		server = Goliath::Server.new('0.0.0.0', ARGV[5].to_i)
1135		server.api = WebhookHandler.new
1136		server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
1137		server.logger = Log4r::Logger.new('goliath')
1138		server.logger.add(Log4r::StdoutOutputter.new('console'))
1139		server.logger.level = Log4r::INFO
1140		server.start do
1141			["INT", "TERM"].each do |sig|
1142				trap(sig) do
1143					EM.defer do
1144						puts 'Shutting down gateway...'
1145						SGXcatapult.shutdown
1146
1147						puts 'Gateway has terminated.'
1148						EM.stop
1149					end
1150				end
1151			end
1152		end
1153	end
1154end