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_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 jid
 464				cred_key = "catapult_cred-#{jid}"
 465				REDIS.lrange(cred_key, 0, 0).then { |other_user|
 466					[jid, num_dest] + creds + other_user
 467				}
 468			else
 469				[jid, num_dest] + creds + [nil]
 470			end
 471		}.then { |(jid, num_dest, *creds, other_user)|
 472			# if destination user is in the system pass on directly
 473			if other_user and not other_user.start_with? 'u-'
 474				pass_on_message(m, creds.last, jid)
 475			else
 476				to_catapult_possible_oob(m, num_dest, *creds)
 477			end
 478		}.catch { |e|
 479			if e.is_a?(Array) && e.length == 2
 480				write_to_stream error_msg(m.reply, m.body, *e)
 481			else
 482				EMPromise.reject(e)
 483			end
 484		}
 485	end
 486
 487	def self.user_cap_identities
 488		[{category: 'client', type: 'sms'}]
 489	end
 490
 491	# TODO: must re-add stuff so can do ad-hoc commands
 492	def self.user_cap_features
 493		[
 494			"urn:xmpp:receipts",
 495		]
 496	end
 497
 498	def self.add_gateway_feature(feature)
 499		@gateway_features << feature
 500		@gateway_features.uniq!
 501	end
 502
 503	subscription :request? do |p|
 504		puts "PRESENCE1: #{p.inspect}"
 505
 506		# subscriptions are allowed from anyone - send reply immediately
 507		msg = Blather::Stanza::Presence.new
 508		msg.to = p.from
 509		msg.from = p.to
 510		msg.type = :subscribed
 511
 512		puts 'RESPONSE5a: ' + msg.inspect
 513		write_to_stream msg
 514
 515		# send a <presence> immediately; not automatically probed for it
 516		# TODO: refactor so no "presence :probe? do |p|" duplicate below
 517		caps = Blather::Stanza::Capabilities.new
 518		# TODO: user a better node URI (?)
 519		caps.node = 'http://catapult.sgx.soprani.ca/'
 520		caps.identities = user_cap_identities
 521		caps.features = user_cap_features
 522
 523		msg = caps.c
 524		msg.to = p.from
 525		msg.from = p.to.to_s + '/sgx'
 526
 527		puts 'RESPONSE5b: ' + msg.inspect
 528		write_to_stream msg
 529
 530		# need to subscribe back so Conversations displays images inline
 531		msg = Blather::Stanza::Presence.new
 532		msg.to = p.from.to_s.split('/', 2)[0]
 533		msg.from = p.to.to_s.split('/', 2)[0]
 534		msg.type = :subscribe
 535
 536		puts 'RESPONSE5c: ' + msg.inspect
 537		write_to_stream msg
 538	end
 539
 540	presence :probe? do |p|
 541		puts 'PRESENCE2: ' + p.inspect
 542
 543		caps = Blather::Stanza::Capabilities.new
 544		# TODO: user a better node URI (?)
 545		caps.node = 'http://catapult.sgx.soprani.ca/'
 546		caps.identities = user_cap_identities
 547		caps.features = user_cap_features
 548
 549		msg = caps.c
 550		msg.to = p.from
 551		msg.from = p.to.to_s + '/sgx'
 552
 553		puts 'RESPONSE6: ' + msg.inspect
 554		write_to_stream msg
 555	end
 556
 557	iq '/iq/ns:query', ns:	'http://jabber.org/protocol/disco#info' do |i|
 558		# respond to capabilities request for an sgx-bwmsgsv2 number JID
 559		if i.to.node
 560			# TODO: confirm the node URL is expected using below
 561			#puts "XR[node]: #{xpath_result[0]['node']}"
 562
 563			msg = i.reply
 564			msg.node = i.node
 565			msg.identities = user_cap_identities
 566			msg.features = user_cap_features
 567
 568			puts 'RESPONSE7: ' + msg.inspect
 569			write_to_stream msg
 570			next
 571		end
 572
 573		# respond to capabilities request for sgx-bwmsgsv2 itself
 574		msg = i.reply
 575		msg.node = i.node
 576		msg.identities = [{
 577			name: 'Soprani.ca Gateway to XMPP - Bandwidth API V2',
 578			type: 'sms', category: 'gateway'
 579		}]
 580		msg.features = @gateway_features
 581		write_to_stream msg
 582	end
 583
 584	def self.check_then_register(i, *creds)
 585		jid_key = "catapult_jid-#{creds.last}"
 586		bare_jid = i.from.stripped
 587		cred_key = "catapult_cred-#{bare_jid}"
 588
 589		REDIS.get(jid_key).then { |existing_jid|
 590			if existing_jid && existing_jid != bare_jid
 591				# TODO: add/log text: credentials exist already
 592				EMPromise.reject([:cancel, 'conflict'])
 593			end
 594		}.then {
 595			REDIS.lrange(cred_key, 0, 3)
 596		}.then { |existing_creds|
 597			# TODO: add/log text: credentials exist already
 598			if existing_creds.length == 4 && creds != existing_creds
 599				EMPromise.reject([:cancel, 'conflict'])
 600			elsif existing_creds.length < 4
 601				REDIS.rpush(cred_key, *creds).then { |length|
 602					if length != 4
 603						EMPromise.reject([
 604							:cancel,
 605							'internal-server-error'
 606						])
 607					end
 608				}
 609			end
 610		}.then {
 611			# not necessary if existing_jid non-nil, easier this way
 612			REDIS.set(jid_key, bare_jid)
 613		}.then { |result|
 614			if result != 'OK'
 615				# TODO: add txt re push failure
 616				EMPromise.reject(
 617					[:cancel, 'internal-server-error']
 618				)
 619			end
 620		}.then {
 621			write_to_stream i.reply
 622		}
 623	end
 624
 625	def self.creds_from_registration_query(qn)
 626		xn = qn.children.find { |v| v.element_name == "x" }
 627
 628		if xn
 629			xn.children.each_with_object({}) do |field, h|
 630				next if field.element_name != "field"
 631				val = field.children.find { |v|
 632					v.element_name == "value"
 633				}
 634
 635				case field['var']
 636				when 'nick'
 637					h[:user_id] = val.text
 638				when 'username'
 639					h[:api_token] = val.text
 640				when 'password'
 641					h[:api_secret] = val.text
 642				when 'phone'
 643					h[:phone_num] = val.text
 644				else
 645					# TODO: error
 646					puts "?: #{field['var']}"
 647				end
 648			end
 649		else
 650			qn.children.each_with_object({}) do |field, h|
 651				case field.element_name
 652				when "nick"
 653					h[:user_id] = field.text
 654				when "username"
 655					h[:api_token] = field.text
 656				when "password"
 657					h[:api_secret] = field.text
 658				when "phone"
 659					h[:phone_num] = field.text
 660				end
 661			end
 662		end.values_at(:user_id, :api_token, :api_secret, :phone_num)
 663	end
 664
 665	def self.process_registration(i, qn)
 666		EMPromise.resolve(
 667			qn.children.find { |v| v.element_name == "remove" }
 668		).then { |rn|
 669			if rn
 670				puts "received <remove/> - ignoring for now..."
 671				EMPromise.reject(:done)
 672			else
 673				creds_from_registration_query(qn)
 674			end
 675		}.then { |user_id, api_token, api_secret, phone_num|
 676			if phone_num[0] == '+'
 677				[user_id, api_token, api_secret, phone_num]
 678			else
 679				# TODO: add text re number not (yet) supported
 680				EMPromise.reject([:cancel, 'item-not-found'])
 681			end
 682		}.then { |user_id, api_token, api_secret, phone_num|
 683			# TODO: find way to verify #{phone_num}, too
 684			call_catapult(
 685				api_token,
 686				api_secret,
 687				:get,
 688				"api/v2/users/#{user_id}/media"
 689			).then { |response|
 690				params = JSON.parse(response)
 691				# TODO: confirm params is array - could be empty
 692
 693				puts "register got str #{response.to_s[0..999]}"
 694
 695				check_then_register(
 696					i,
 697					user_id,
 698					api_token,
 699					api_secret,
 700					phone_num
 701				)
 702			}
 703		}.catch { |e|
 704			EMPromise.reject(case e
 705			when 401
 706				# TODO: add text re bad credentials
 707				[:auth, 'not-authorized']
 708			when 404
 709				# TODO: add text re number not found or disabled
 710				[:cancel, 'item-not-found']
 711			when Integer
 712				[:modify, 'not-acceptable']
 713			else
 714				e
 715			end)
 716		}
 717	end
 718
 719	def self.registration_form(orig, existing_number=nil)
 720		msg = Nokogiri::XML::Node.new 'query', orig.document
 721		msg['xmlns'] = 'jabber:iq:register'
 722
 723		if existing_number
 724			msg.add_child(
 725				Nokogiri::XML::Node.new(
 726					'registered', msg.document
 727				)
 728			)
 729		end
 730
 731		# TODO: update "User Id" x2 below (to "accountId"?), and others?
 732		n1 = Nokogiri::XML::Node.new(
 733			'instructions', msg.document
 734		)
 735		n1.content = "Enter the information from your Account "\
 736			"page as well as the Phone Number\nin your "\
 737			"account you want to use (ie. '+12345678901')"\
 738			".\nUser Id is nick, API Token is username, "\
 739			"API Secret is password, Phone Number is phone"\
 740			".\n\nThe source code for this gateway is at "\
 741			"https://gitlab.com/soprani.ca/sgx-bwmsgsv2 ."\
 742			"\nCopyright (C) 2017-2020  Denver Gingerich "\
 743			"and others, licensed under AGPLv3+."
 744		n2 = Nokogiri::XML::Node.new 'nick', msg.document
 745		n3 = Nokogiri::XML::Node.new 'username', msg.document
 746		n4 = Nokogiri::XML::Node.new 'password', msg.document
 747		n5 = Nokogiri::XML::Node.new 'phone', msg.document
 748		n5.content = existing_number.to_s
 749		msg.add_child(n1)
 750		msg.add_child(n2)
 751		msg.add_child(n3)
 752		msg.add_child(n4)
 753		msg.add_child(n5)
 754
 755		x = Blather::Stanza::X.new :form, [
 756			{
 757				required: true, type: :"text-single",
 758				label: 'User Id', var: 'nick'
 759			},
 760			{
 761				required: true, type: :"text-single",
 762				label: 'API Token', var: 'username'
 763			},
 764			{
 765				required: true, type: :"text-private",
 766				label: 'API Secret', var: 'password'
 767			},
 768			{
 769				required: true, type: :"text-single",
 770				label: 'Phone Number', var: 'phone',
 771				value: existing_number.to_s
 772			}
 773		]
 774		x.title = 'Register for '\
 775			'Soprani.ca Gateway to XMPP - Bandwidth API V2'
 776		x.instructions = "Enter the details from your Account "\
 777			"page as well as the Phone Number\nin your "\
 778			"account you want to use (ie. '+12345678901')"\
 779			".\n\nThe source code for this gateway is at "\
 780			"https://gitlab.com/soprani.ca/sgx-bwmsgsv2 ."\
 781			"\nCopyright (C) 2017-2020  Denver Gingerich "\
 782			"and others, licensed under AGPLv3+."
 783		msg.add_child(x)
 784
 785		orig.add_child(msg)
 786
 787		return orig
 788	end
 789
 790	iq '/iq/ns:query', ns: 'jabber:iq:register' do |i, qn|
 791		puts "IQ: #{i.inspect}"
 792
 793		case i.type
 794		when :set
 795			process_registration(i, qn)
 796		when :get
 797			bare_jid = i.from.stripped
 798			cred_key = "catapult_cred-#{bare_jid}"
 799			REDIS.lindex(cred_key, 3).then { |existing_number|
 800				reply = registration_form(i.reply, existing_number)
 801				puts "RESPONSE2: #{reply.inspect}"
 802				write_to_stream reply
 803			}
 804		else
 805			# Unknown IQ, ignore for now
 806			EMPromise.reject(:done)
 807		end.catch { |e|
 808			if e.is_a?(Array) && e.length == 2
 809				write_to_stream error_msg(i.reply, qn, *e)
 810			elsif e != :done
 811				EMPromise.reject(e)
 812			end
 813		}.catch(&method(:panic))
 814	end
 815
 816	iq :get? do |i|
 817		write_to_stream error_msg(i.reply, i.children, 'cancel', 'feature-not-implemented')
 818	end
 819
 820	iq :set? do |i|
 821		write_to_stream error_msg(i.reply, i.children, 'cancel', 'feature-not-implemented')
 822	end
 823end
 824
 825class ReceiptMessage < Blather::Stanza
 826	def self.new(to=nil)
 827		node = super :message
 828		node.to = to
 829		node
 830	end
 831end
 832
 833class WebhookHandler < Goliath::API
 834	use Goliath::Rack::Params
 835
 836	def response(env)
 837		# TODO: add timestamp grab here, and MUST include ./tai version
 838
 839		puts 'ENV: ' + env.reject{ |k| k == 'params' }.to_s
 840
 841		# TODO: process each message in list, not just first one
 842		jparams = params['_json'][0]['message']
 843
 844		type = params['_json'][0]['type']
 845
 846		users_num = ''
 847		others_num = ''
 848		if jparams['direction'] == 'in'
 849			users_num = jparams['owner']
 850			others_num = jparams['from']
 851		elsif jparams['direction'] == 'out'
 852			users_num = jparams['from']
 853			others_num = jparams['owner']
 854		else
 855			# TODO: exception or similar
 856			puts "big prob: '" + jparams['direction'] + "'" + body
 857			return [200, {}, "OK"]
 858		end
 859
 860		puts 'BODY - messageId: ' + jparams['id'] +
 861			', eventType: ' + type +
 862			', time: ' + jparams['time'] +
 863			', direction: ' + jparams['direction'] +
 864			#', state: ' + jparams['state'] +
 865			', deliveryState: ' + (jparams['deliveryState'] ?
 866				jparams['deliveryState'] : 'NONE') +
 867			', deliveryCode: ' + (jparams['deliveryCode'] ?
 868				jparams['deliveryCode'] : 'NONE') +
 869			', deliveryDesc: ' + (jparams['deliveryDescription'] ?
 870				jparams['deliveryDescription'] : 'NONE') +
 871			', tag: ' + (jparams['tag'] ? jparams['tag'] : 'NONE') +
 872			', media: ' + (jparams['media'] ?
 873				jparams['media'].to_s : 'NONE')
 874
 875		if others_num[0] != '+'
 876			# TODO: check that others_num actually a shortcode first
 877			others_num +=
 878				';phone-context=ca-us.phone-context.soprani.ca'
 879		end
 880
 881		jid_key = "catapult_jid-#{users_num}"
 882		bare_jid = REDIS.get(jid_key).promise.sync
 883
 884		if !bare_jid
 885			puts "jid_key (#{jid_key}) DNE; BW API misconfigured?"
 886
 887			# TODO: likely not appropriate; give error to BW API?
 888			# TODO: add text re credentials not being registered
 889			#write_to_stream error_msg(m.reply, m.body, :auth,
 890			#	'registration-required')
 891			return [200, {}, "OK"]
 892		end
 893
 894		msg = nil
 895		case jparams['direction']
 896		when 'in'
 897			text = ''
 898			case type
 899			when 'sms'
 900				text = jparams['text']
 901			when 'mms'
 902				has_media = false
 903
 904				if jparams['text'].empty?
 905					if not has_media
 906						text = '[suspected group msg '\
 907							'with no text (odd)]'
 908					end
 909				else
 910					text = if has_media
 911						# TODO: write/use a caption XEP
 912						jparams['text']
 913					else
 914						'[suspected group msg '\
 915						'(recipient list not '\
 916						'available) with '\
 917						'following text] ' +
 918						jparams['text']
 919					end
 920				end
 921
 922				# ie. if text param non-empty or had no media
 923				if not text.empty?
 924					msg = Blather::Stanza::Message.new(
 925						bare_jid, text)
 926					msg.from = others_num + '@' + ARGV[0]
 927					SGXbwmsgsv2.write(msg)
 928				end
 929
 930				return [200, {}, "OK"]
 931			when 'message-received'
 932				# TODO: handle group chat, and fix above
 933				text = jparams['text']
 934
 935				if jparams['to'].length > 1
 936					msg = Blather::Stanza::Message.new(
 937						'cheogram.com', text)  # TODO
 938
 939					addrs = Nokogiri::XML::Node.new(
 940						'addresses', msg.document)
 941					addrs['xmlns'] = 'http://jabber.org/' +
 942						'protocol/address'
 943
 944					addr1 = Nokogiri::XML::Node.new(
 945						'address', msg.document)
 946					addr1['type'] = 'to'
 947					addr1['jid'] = bare_jid
 948					addrs.add_child(addr1)
 949
 950					jparams['to'].each do |receiver|
 951						if receiver == users_num
 952							# already there in addr1
 953							next
 954						end
 955
 956						addrn = Nokogiri::XML::Node.new(
 957							'address', msg.document)
 958						addrn['type'] = 'to'
 959						addrn['uri'] = "sms:#{receiver}"
 960						addrn['delivered'] = 'true'
 961						addrs.add_child(addrn)
 962					end
 963
 964					msg.add_child(addrs)
 965
 966					# TODO: delete
 967					puts "RESPONSE9: #{msg.inspect}"
 968				end
 969
 970				jparams['media'].each do |media_url|
 971					if not media_url.end_with?(
 972						'.smil', '.txt', '.xml'
 973					)
 974
 975						has_media = true
 976						SGXbwmsgsv2.send_media(
 977							others_num + '@' +
 978							ARGV[0],
 979							bare_jid, media_url,
 980							nil, nil, msg
 981						)
 982					end
 983				end unless not jparams['media']
 984			else
 985				text = "unknown type (#{type})"\
 986					" with text: " + jparams['text']
 987
 988				# TODO: log/notify of this properly
 989				puts text
 990			end
 991
 992			if not msg
 993				msg = Blather::Stanza::Message.new(bare_jid,
 994					text)
 995			end
 996		else # per prior switch, this is:  jparams['direction'] == 'out'
 997			tag_parts = jparams['tag'].split(/ /, 2)
 998			id = WEBrick::HTTPUtils.unescape(tag_parts[0])
 999			resourcepart = WEBrick::HTTPUtils.unescape(tag_parts[1])
1000
1001			# TODO: remove this hack
1002			if jparams['to'].length > 1
1003				puts "WARN! group no rcpt: #{users_num}"
1004				return [200, {}, "OK"]
1005			end
1006
1007			case type
1008			when 'message-failed'
1009				# create a bare message like the one user sent
1010				msg = Blather::Stanza::Message.new(
1011					others_num + '@' + ARGV[0])
1012				msg.from = bare_jid + '/' + resourcepart
1013				msg['id'] = id
1014
1015				# TODO: add 'errorCode' and/or 'description' val
1016				# create an error reply to the bare message
1017				msg = Blather::StanzaError.new(
1018					msg,
1019					'recipient-unavailable',
1020					:wait
1021				).to_node
1022
1023				# TODO: make prettier: this should be done above
1024				others_num = params['_json'][0]['to']
1025			when 'message-delivered'
1026
1027				msg = ReceiptMessage.new(bare_jid)
1028
1029				# TODO: put in member/instance variable
1030				msg['id'] = SecureRandom.uuid
1031
1032				# TODO: send only when requested per XEP-0184
1033				rcvd = Nokogiri::XML::Node.new(
1034					'received',
1035					msg.document
1036				)
1037				rcvd['xmlns'] = 'urn:xmpp:receipts'
1038				rcvd['id'] = id
1039				msg.add_child(rcvd)
1040
1041				# TODO: make prettier: this should be done above
1042				others_num = params['_json'][0]['to']
1043			else
1044				# TODO: notify somehow of unknown state receivd?
1045				puts "message with id #{id} has "\
1046					"other type #{type}"
1047				return [200, {}, "OK"]
1048			end
1049
1050			puts "RESPONSE4: #{msg.inspect}"
1051		end
1052
1053		msg.from = others_num + '@' + ARGV[0]
1054		SGXbwmsgsv2.write(msg)
1055
1056		[200, {}, "OK"]
1057
1058	rescue Exception => e
1059		puts 'Shutting down gateway due to exception 013: ' + e.message
1060		SGXbwmsgsv2.shutdown
1061		puts 'Gateway has terminated.'
1062		EM.stop
1063	end
1064end
1065
1066at_exit do
1067	$stdout.sync = true
1068
1069	puts "Soprani.ca/SMS Gateway for XMPP - Bandwidth API V2\n"\
1070		"==>> last commit of this version is " + `git rev-parse HEAD` + "\n"
1071
1072	if ARGV.size != 7 and ARGV.size != 8
1073		puts "Usage: sgx-bwmsgsv2.rb <component_jid> "\
1074			"<component_password> <server_hostname> "\
1075			"<server_port> <application_id> "\
1076			"<http_listen_port> <mms_proxy_prefix_url> [V1_creds_file]"
1077		exit 0
1078	end
1079
1080	t = Time.now
1081	puts "LOG %d.%09d: starting...\n\n" % [t.to_i, t.nsec]
1082
1083	EM.run do
1084		REDIS = EM::Hiredis.connect
1085
1086		SGXbwmsgsv2.run
1087
1088		# required when using Prosody otherwise disconnects on 6-hour inactivity
1089		EM.add_periodic_timer(3600) do
1090			msg = Blather::Stanza::Iq::Ping.new(:get, 'localhost')
1091			msg.from = ARGV[0]
1092			SGXbwmsgsv2.write(msg)
1093		end
1094
1095		server = Goliath::Server.new('0.0.0.0', ARGV[5].to_i)
1096		server.api = WebhookHandler.new
1097		server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
1098		server.logger = Log4r::Logger.new('goliath')
1099		server.logger.add(Log4r::StdoutOutputter.new('console'))
1100		server.logger.level = Log4r::INFO
1101		server.start do
1102			["INT", "TERM"].each do |sig|
1103				trap(sig) do
1104					EM.defer do
1105						puts 'Shutting down gateway...'
1106						SGXbwmsgsv2.shutdown
1107
1108						puts 'Gateway has terminated.'
1109						EM.stop
1110					end
1111				end
1112			end
1113		end
1114	end
1115end