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