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