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