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 :get? do |i|
 789		write_to_stream(error_msg(
 790			i.reply,
 791			i.children,
 792			'cancel',
 793			'feature-not-implemented'
 794		))
 795	end
 796
 797	iq :set? do |i|
 798		write_to_stream(error_msg(
 799			i.reply,
 800			i.children,
 801			'cancel',
 802			'feature-not-implemented'
 803		))
 804	end
 805end
 806
 807class ReceiptMessage < Blather::Stanza
 808	def self.new(to=nil)
 809		node = super :message
 810		node.to = to
 811		node
 812	end
 813end
 814
 815class WebhookHandler < Goliath::API
 816	use Goliath::Rack::Params
 817
 818	def response(env)
 819		# TODO: add timestamp grab here, and MUST include ./tai version
 820
 821		puts 'ENV: ' + env.reject { |k| k == 'params' }.to_s
 822
 823		if params.empty?
 824			puts 'PARAMS empty!'
 825			return [200, {}, "OK"]
 826		end
 827
 828		if env['REQUEST_URI'] != '/'
 829			puts 'BADREQUEST1: non-/ request "' +
 830				env['REQUEST_URI'] + '", method "' +
 831				env['REQUEST_METHOD'] + '"'
 832			return [200, {}, "OK"]
 833		end
 834
 835		if env['REQUEST_METHOD'] != 'POST'
 836			puts 'BADREQUEST2: non-POST request; URI: "' +
 837				env['REQUEST_URI'] + '", method "' +
 838				env['REQUEST_METHOD'] + '"'
 839			return [200, {}, "OK"]
 840		end
 841
 842		# TODO: process each message in list, not just first one
 843		jparams = params['_json'][0]['message']
 844
 845		type = params['_json'][0]['type']
 846
 847		users_num = ''
 848		others_num = ''
 849		if jparams['direction'] == 'in'
 850			users_num = jparams['owner']
 851			others_num = jparams['from']
 852		elsif jparams['direction'] == 'out'
 853			users_num = jparams['from']
 854			others_num = jparams['owner']
 855		else
 856			# TODO: exception or similar
 857			puts "big prob: '" + jparams['direction'] + "'" + body
 858			return [200, {}, "OK"]
 859		end
 860
 861		puts 'BODY - messageId: ' + jparams['id'] +
 862			', eventType: ' + type +
 863			', time: ' + jparams['time'] +
 864			', direction: ' + jparams['direction'] +
 865			#', state: ' + jparams['state'] +
 866			', deliveryState: ' + (jparams['deliveryState'] ?
 867				jparams['deliveryState'] : 'NONE') +
 868			', deliveryCode: ' + (jparams['deliveryCode'] ?
 869				jparams['deliveryCode'] : 'NONE') +
 870			', deliveryDesc: ' + (jparams['deliveryDescription'] ?
 871				jparams['deliveryDescription'] : 'NONE') +
 872			', tag: ' + (jparams['tag'] ? jparams['tag'] : 'NONE') +
 873			', media: ' + (jparams['media'] ?
 874				jparams['media'].to_s : 'NONE')
 875
 876		if others_num[0] != '+'
 877			# TODO: check that others_num actually a shortcode first
 878			others_num +=
 879				';phone-context=ca-us.phone-context.soprani.ca'
 880		end
 881
 882		jid_key = "catapult_jid-#{users_num}"
 883		bare_jid = REDIS.get(jid_key).promise.sync
 884
 885		if !bare_jid
 886			puts "jid_key (#{jid_key}) DNE; BW API misconfigured?"
 887
 888			# TODO: likely not appropriate; give error to BW API?
 889			# TODO: add text re credentials not being registered
 890			#write_to_stream error_msg(m.reply, m.body, :auth,
 891			#	'registration-required')
 892			return [200, {}, "OK"]
 893		end
 894
 895		msg = nil
 896		case jparams['direction']
 897		when 'in'
 898			text = ''
 899			case type
 900			when 'sms'
 901				text = jparams['text']
 902			when 'mms'
 903				has_media = false
 904
 905				if jparams['text'].empty?
 906					if not has_media
 907						text = '[suspected group msg '\
 908							'with no text (odd)]'
 909					end
 910				else
 911					text = if has_media
 912						# TODO: write/use a caption XEP
 913						jparams['text']
 914					else
 915						'[suspected group msg '\
 916						'(recipient list not '\
 917						'available) with '\
 918						'following text] ' +
 919						jparams['text']
 920					end
 921				end
 922
 923				# ie. if text param non-empty or had no media
 924				if not text.empty?
 925					msg = Blather::Stanza::Message.new(
 926						bare_jid, text)
 927					msg.from = others_num + '@' + ARGV[0]
 928					SGXbwmsgsv2.write(msg)
 929				end
 930
 931				return [200, {}, "OK"]
 932			when 'message-received'
 933				# TODO: handle group chat, and fix above
 934				text = jparams['text']
 935
 936				if jparams['to'].length > 1
 937					# TODO
 938					msg = Blather::Stanza::Message.new(
 939						'cheogram.com',
 940						text
 941					)
 942
 943					addrs = Nokogiri::XML::Node.new(
 944						'addresses', msg.document)
 945					addrs['xmlns'] = 'http://jabber.org/' \
 946						'protocol/address'
 947
 948					addr1 = Nokogiri::XML::Node.new(
 949						'address', msg.document)
 950					addr1['type'] = 'to'
 951					addr1['jid'] = bare_jid
 952					addrs.add_child(addr1)
 953
 954					jparams['to'].each do |receiver|
 955						if receiver == users_num
 956							# already there in addr1
 957							next
 958						end
 959
 960						addrn = Nokogiri::XML::Node.new(
 961							'address', msg.document)
 962						addrn['type'] = 'to'
 963						addrn['uri'] = "sms:#{receiver}"
 964						addrn['delivered'] = 'true'
 965						addrs.add_child(addrn)
 966					end
 967
 968					msg.add_child(addrs)
 969
 970					# TODO: delete
 971					puts "RESPONSE9: #{msg.inspect}"
 972				end
 973
 974				Array(jparams['media']).each do |media_url|
 975					unless media_url.end_with?(
 976						'.smil', '.txt', '.xml'
 977					)
 978						has_media = true
 979						SGXbwmsgsv2.send_media(
 980							others_num + '@' +
 981							ARGV[0],
 982							bare_jid, media_url,
 983							nil, nil, msg
 984						)
 985					end
 986				end
 987			else
 988				text = "unknown type (#{type})"\
 989					" with text: " + jparams['text']
 990
 991				# TODO: log/notify of this properly
 992				puts text
 993			end
 994
 995			if not msg
 996				msg = Blather::Stanza::Message.new(bare_jid,
 997					text)
 998			end
 999		else # per prior switch, this is:  jparams['direction'] == 'out'
1000			tag_parts = jparams['tag'].split(/ /, 2)
1001			id = WEBrick::HTTPUtils.unescape(tag_parts[0])
1002			resourcepart = WEBrick::HTTPUtils.unescape(tag_parts[1])
1003
1004			# TODO: remove this hack
1005			if jparams['to'].length > 1
1006				puts "WARN! group no rcpt: #{users_num}"
1007				return [200, {}, "OK"]
1008			end
1009
1010			case type
1011			when 'message-failed'
1012				# create a bare message like the one user sent
1013				msg = Blather::Stanza::Message.new(
1014					others_num + '@' + ARGV[0])
1015				msg.from = bare_jid + '/' + resourcepart
1016				msg['id'] = id
1017
1018				# TODO: add 'errorCode' and/or 'description' val
1019				# create an error reply to the bare message
1020				msg = Blather::StanzaError.new(
1021					msg,
1022					'recipient-unavailable',
1023					:wait
1024				).to_node
1025
1026				# TODO: make prettier: this should be done above
1027				others_num = params['_json'][0]['to']
1028			when 'message-delivered'
1029
1030				msg = ReceiptMessage.new(bare_jid)
1031
1032				# TODO: put in member/instance variable
1033				msg['id'] = SecureRandom.uuid
1034
1035				# TODO: send only when requested per XEP-0184
1036				rcvd = Nokogiri::XML::Node.new(
1037					'received',
1038					msg.document
1039				)
1040				rcvd['xmlns'] = 'urn:xmpp:receipts'
1041				rcvd['id'] = id
1042				msg.add_child(rcvd)
1043
1044				# TODO: make prettier: this should be done above
1045				others_num = params['_json'][0]['to']
1046			else
1047				# TODO: notify somehow of unknown state receivd?
1048				puts "message with id #{id} has "\
1049					"other type #{type}"
1050				return [200, {}, "OK"]
1051			end
1052
1053			puts "RESPONSE4: #{msg.inspect}"
1054		end
1055
1056		msg.from = others_num + '@' + ARGV[0]
1057		SGXbwmsgsv2.write(msg)
1058
1059		[200, {}, "OK"]
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