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