sgx-bwmsgsv2.rb

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