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