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				puts "received <remove/> - ignoring for now..."
 601				EMPromise.reject(:done)
 602			else
 603				creds_from_registration_query(qn)
 604			end
 605		}.then { |user_id, api_token, api_secret, phone_num|
 606			if phone_num[0] == '+'
 607				[user_id, api_token, api_secret, phone_num]
 608			else
 609				# TODO: add text re number not (yet) supported
 610				EMPromise.reject([:cancel, 'item-not-found'])
 611			end
 612		}.then { |user_id, api_token, api_secret, phone_num|
 613			# TODO: find way to verify #{phone_num}, too
 614			call_catapult(
 615				api_token,
 616				api_secret,
 617				:get,
 618				"api/v2/users/#{user_id}/media"
 619			).then { |response|
 620				JSON.parse(response)
 621				# TODO: confirm response is array - could be empty
 622
 623				puts "register got str #{response.to_s[0..999]}"
 624
 625				check_then_register(
 626					i,
 627					user_id,
 628					api_token,
 629					api_secret,
 630					phone_num
 631				)
 632			}
 633		}.catch { |e|
 634			EMPromise.reject(case e
 635			when 401
 636				# TODO: add text re bad credentials
 637				[:auth, 'not-authorized']
 638			when 404
 639				# TODO: add text re number not found or disabled
 640				[:cancel, 'item-not-found']
 641			when Integer
 642				[:modify, 'not-acceptable']
 643			else
 644				e
 645			end)
 646		}
 647	end
 648
 649	def self.registration_form(orig, existing_number=nil)
 650		msg = Nokogiri::XML::Node.new 'query', orig.document
 651		msg['xmlns'] = 'jabber:iq:register'
 652
 653		if existing_number
 654			msg.add_child(
 655				Nokogiri::XML::Node.new(
 656					'registered', msg.document
 657				)
 658			)
 659		end
 660
 661		# TODO: update "User Id" x2 below (to "accountId"?), and others?
 662		n1 = Nokogiri::XML::Node.new(
 663			'instructions', msg.document
 664		)
 665		n1.content = "Enter the information from your Account "\
 666			"page as well as the Phone Number\nin your "\
 667			"account you want to use (ie. '+12345678901')"\
 668			".\nUser Id is nick, API Token is username, "\
 669			"API Secret is password, Phone Number is phone"\
 670			".\n\nThe source code for this gateway is at "\
 671			"https://gitlab.com/soprani.ca/sgx-bwmsgsv2 ."\
 672			"\nCopyright (C) 2017-2020  Denver Gingerich "\
 673			"and others, licensed under AGPLv3+."
 674		n2 = Nokogiri::XML::Node.new 'nick', msg.document
 675		n3 = Nokogiri::XML::Node.new 'username', msg.document
 676		n4 = Nokogiri::XML::Node.new 'password', msg.document
 677		n5 = Nokogiri::XML::Node.new 'phone', msg.document
 678		n5.content = existing_number.to_s
 679		msg.add_child(n1)
 680		msg.add_child(n2)
 681		msg.add_child(n3)
 682		msg.add_child(n4)
 683		msg.add_child(n5)
 684
 685		x = Blather::Stanza::X.new :form, [
 686			{
 687				required: true, type: :"text-single",
 688				label: 'User Id', var: 'nick'
 689			},
 690			{
 691				required: true, type: :"text-single",
 692				label: 'API Token', var: 'username'
 693			},
 694			{
 695				required: true, type: :"text-private",
 696				label: 'API Secret', var: 'password'
 697			},
 698			{
 699				required: true, type: :"text-single",
 700				label: 'Phone Number', var: 'phone',
 701				value: existing_number.to_s
 702			}
 703		]
 704		x.title = 'Register for '\
 705			'Soprani.ca Gateway to XMPP - Bandwidth API V2'
 706		x.instructions = "Enter the details from your Account "\
 707			"page as well as the Phone Number\nin your "\
 708			"account you want to use (ie. '+12345678901')"\
 709			".\n\nThe source code for this gateway is at "\
 710			"https://gitlab.com/soprani.ca/sgx-bwmsgsv2 ."\
 711			"\nCopyright (C) 2017-2020  Denver Gingerich "\
 712			"and others, licensed under AGPLv3+."
 713		msg.add_child(x)
 714
 715		orig.add_child(msg)
 716
 717		return orig
 718	end
 719
 720	iq '/iq/ns:query', ns: 'jabber:iq:register' do |i, qn|
 721		puts "IQ: #{i.inspect}"
 722
 723		case i.type
 724		when :set
 725			process_registration(i, qn)
 726		when :get
 727			bare_jid = i.from.stripped
 728			@registration_repo.find(bare_jid).then { |creds|
 729				reply = registration_form(i.reply, creds.last)
 730				puts "RESPONSE2: #{reply.inspect}"
 731				write_to_stream reply
 732			}
 733		else
 734			# Unknown IQ, ignore for now
 735			EMPromise.reject(:done)
 736		end.catch { |e|
 737			if e.is_a?(Array) && e.length == 2
 738				write_to_stream error_msg(i.reply, qn, *e)
 739			elsif e != :done
 740				EMPromise.reject(e)
 741			end
 742		}.catch(&method(:panic))
 743	end
 744
 745	iq type: [:get, :set] do |iq|
 746		write_to_stream(Blather::StanzaError.new(
 747			iq,
 748			'feature-not-implemented',
 749			:cancel
 750		))
 751	end
 752end
 753
 754class ReceiptMessage < Blather::Stanza
 755	def self.new(to=nil)
 756		node = super :message
 757		node.to = to
 758		node
 759	end
 760end
 761
 762class WebhookHandler < Goliath::API
 763	use Goliath::Rack::Params
 764
 765	@registration_repo = RegistrationRepo.new
 766
 767	def response(env)
 768		# TODO: add timestamp grab here, and MUST include ./tai version
 769
 770		puts 'ENV: ' + env.reject { |k| k == 'params' }.to_s
 771
 772		if params.empty?
 773			puts 'PARAMS empty!'
 774			return [200, {}, "OK"]
 775		end
 776
 777		if env['REQUEST_URI'] != '/'
 778			puts 'BADREQUEST1: non-/ request "' +
 779				env['REQUEST_URI'] + '", method "' +
 780				env['REQUEST_METHOD'] + '"'
 781			return [200, {}, "OK"]
 782		end
 783
 784		if env['REQUEST_METHOD'] != 'POST'
 785			puts 'BADREQUEST2: non-POST request; URI: "' +
 786				env['REQUEST_URI'] + '", method "' +
 787				env['REQUEST_METHOD'] + '"'
 788			return [200, {}, "OK"]
 789		end
 790
 791		# TODO: process each message in list, not just first one
 792		jparams = params['_json'][0]['message']
 793
 794		type = params['_json'][0]['type']
 795
 796		users_num = ''
 797		others_num = ''
 798		if jparams['direction'] == 'in'
 799			users_num = jparams['owner']
 800			others_num = jparams['from']
 801		elsif jparams['direction'] == 'out'
 802			users_num = jparams['from']
 803			others_num = jparams['owner']
 804		else
 805			# TODO: exception or similar
 806			puts "big prob: '" + jparams['direction'] + "'" + body
 807			return [200, {}, "OK"]
 808		end
 809
 810		puts 'BODY - messageId: ' + jparams['id'] +
 811			', eventType: ' + type +
 812			', time: ' + jparams['time'] +
 813			', direction: ' + jparams['direction'] +
 814			#', state: ' + jparams['state'] +
 815			', deliveryState: ' + (jparams['deliveryState'] ?
 816				jparams['deliveryState'] : 'NONE') +
 817			', errorCode: ' + (jparams['errorCode'] ?
 818				jparams['errorCode'] : 'NONE') +
 819			', description: ' + (jparams['description'] ?
 820				jparams['description'] : 'NONE') +
 821			', tag: ' + (jparams['tag'] ? jparams['tag'] : 'NONE') +
 822			', media: ' + (jparams['media'] ?
 823				jparams['media'].to_s : 'NONE')
 824
 825		if others_num[0] != '+'
 826			# TODO: check that others_num actually a shortcode first
 827			others_num +=
 828				';phone-context=ca-us.phone-context.soprani.ca'
 829		end
 830
 831		bare_jid = @registration_repo.find_jid(users_num).sync
 832
 833		if !bare_jid
 834			puts "jid_key (#{jid_key}) DNE; BW API misconfigured?"
 835
 836			# TODO: likely not appropriate; give error to BW API?
 837			# TODO: add text re credentials not being registered
 838			#write_to_stream error_msg(m.reply, m.body, :auth,
 839			#	'registration-required')
 840			return [200, {}, "OK"]
 841		end
 842
 843		msg = nil
 844		case jparams['direction']
 845		when 'in'
 846			text = ''
 847			case type
 848			when 'sms'
 849				text = jparams['text']
 850			when 'mms'
 851				has_media = false
 852
 853				if jparams['text'].empty?
 854					if not has_media
 855						text = '[suspected group msg '\
 856							'with no text (odd)]'
 857					end
 858				else
 859					text = if has_media
 860						# TODO: write/use a caption XEP
 861						jparams['text']
 862					else
 863						'[suspected group msg '\
 864						'(recipient list not '\
 865						'available) with '\
 866						'following text] ' +
 867						jparams['text']
 868					end
 869				end
 870
 871				# ie. if text param non-empty or had no media
 872				if not text.empty?
 873					msg = Blather::Stanza::Message.new(
 874						bare_jid, text)
 875					msg.from = others_num + '@' + ARGV[0]
 876					SGXbwmsgsv2.write(msg)
 877				end
 878
 879				return [200, {}, "OK"]
 880			when 'message-received'
 881				# TODO: handle group chat, and fix above
 882				text = jparams['text']
 883
 884				if jparams['to'].length > 1
 885					msg = Blather::Stanza::Message.new(
 886						Blather::JID.new(bare_jid).domain,
 887						text
 888					)
 889
 890					addrs = Nokogiri::XML::Node.new(
 891						'addresses', msg.document)
 892					addrs['xmlns'] = 'http://jabber.org/' \
 893						'protocol/address'
 894
 895					addr1 = Nokogiri::XML::Node.new(
 896						'address', msg.document)
 897					addr1['type'] = 'to'
 898					addr1['jid'] = bare_jid
 899					addrs.add_child(addr1)
 900
 901					jparams['to'].each do |receiver|
 902						if receiver == users_num
 903							# already there in addr1
 904							next
 905						end
 906
 907						addrn = Nokogiri::XML::Node.new(
 908							'address', msg.document)
 909						addrn['type'] = 'to'
 910						addrn['uri'] = "sms:#{receiver}"
 911						addrn['delivered'] = 'true'
 912						addrs.add_child(addrn)
 913					end
 914
 915					msg.add_child(addrs)
 916
 917					# TODO: delete
 918					puts "RESPONSE9: #{msg.inspect}"
 919				end
 920
 921				Array(jparams['media']).each do |media_url|
 922					unless media_url.end_with?(
 923						'.smil', '.txt', '.xml'
 924					)
 925						has_media = true
 926						SGXbwmsgsv2.send_media(
 927							others_num + '@' +
 928							ARGV[0],
 929							bare_jid, media_url,
 930							nil, nil, msg
 931						)
 932					end
 933				end
 934			else
 935				text = "unknown type (#{type})"\
 936					" with text: " + jparams['text']
 937
 938				# TODO: log/notify of this properly
 939				puts text
 940			end
 941
 942			if not msg
 943				msg = Blather::Stanza::Message.new(bare_jid, text)
 944			end
 945		else # per prior switch, this is:  jparams['direction'] == 'out'
 946			tag_parts = jparams['tag'].split(/ /, 2)
 947			id = WEBrick::HTTPUtils.unescape(tag_parts[0])
 948			resourcepart = WEBrick::HTTPUtils.unescape(tag_parts[1])
 949
 950			# TODO: remove this hack
 951			if jparams['to'].length > 1
 952				puts "WARN! group no rcpt: #{users_num}"
 953				return [200, {}, "OK"]
 954			end
 955
 956			case type
 957			when 'message-failed'
 958				# create a bare message like the one user sent
 959				msg = Blather::Stanza::Message.new(
 960					others_num + '@' + ARGV[0])
 961				msg.from = bare_jid + '/' + resourcepart
 962				msg['id'] = id
 963
 964				# TODO: add 'errorCode' and/or 'description' val
 965				# create an error reply to the bare message
 966				msg = msg.as_error(
 967					'recipient-unavailable',
 968					:wait,
 969					jparams['description']
 970				)
 971
 972				# TODO: make prettier: this should be done above
 973				others_num = params['_json'][0]['to']
 974			when 'message-delivered'
 975
 976				msg = ReceiptMessage.new(bare_jid)
 977
 978				# TODO: put in member/instance variable
 979				msg['id'] = SecureRandom.uuid
 980
 981				# TODO: send only when requested per XEP-0184
 982				rcvd = Nokogiri::XML::Node.new(
 983					'received',
 984					msg.document
 985				)
 986				rcvd['xmlns'] = 'urn:xmpp:receipts'
 987				rcvd['id'] = id
 988				msg.add_child(rcvd)
 989
 990				# TODO: make prettier: this should be done above
 991				others_num = params['_json'][0]['to']
 992			else
 993				# TODO: notify somehow of unknown state receivd?
 994				puts "message with id #{id} has "\
 995					"other type #{type}"
 996				return [200, {}, "OK"]
 997			end
 998
 999			puts "RESPONSE4: #{msg.inspect}"
1000		end
1001
1002		msg.from = others_num + '@' + ARGV[0]
1003		SGXbwmsgsv2.write(msg)
1004
1005		[200, {}, "OK"]
1006	rescue Exception => e
1007		puts 'Shutting down gateway due to exception 013: ' + e.message
1008		SGXbwmsgsv2.shutdown
1009		puts 'Gateway has terminated.'
1010		EM.stop
1011	end
1012end
1013
1014at_exit do
1015	$stdout.sync = true
1016
1017	puts "Soprani.ca/SMS Gateway for XMPP - Bandwidth API V2\n"\
1018		"==>> last commit of this version is " + `git rev-parse HEAD` + "\n"
1019
1020	if ARGV.size != 7 and ARGV.size != 8
1021		puts "Usage: sgx-bwmsgsv2.rb <component_jid> "\
1022			"<component_password> <server_hostname> "\
1023			"<server_port> <application_id> "\
1024			"<http_listen_port> <mms_proxy_prefix_url> [V1_creds_file]"
1025		exit 0
1026	end
1027
1028	t = Time.now
1029	puts "LOG %d.%09d: starting...\n\n" % [t.to_i, t.nsec]
1030
1031	EM.run do
1032		REDIS = EM::Hiredis.connect
1033
1034		SGXbwmsgsv2.run
1035
1036		# required when using Prosody otherwise disconnects on 6-hour inactivity
1037		EM.add_periodic_timer(3600) do
1038			msg = Blather::Stanza::Iq::Ping.new(:get, 'localhost')
1039			msg.from = ARGV[0]
1040			SGXbwmsgsv2.write(msg)
1041		end
1042
1043		server = Goliath::Server.new('0.0.0.0', ARGV[5].to_i)
1044		server.api = WebhookHandler.new
1045		server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
1046		server.logger = Log4r::Logger.new('goliath')
1047		server.logger.add(Log4r::StdoutOutputter.new('console'))
1048		server.logger.level = Log4r::INFO
1049		server.start do
1050			["INT", "TERM"].each do |sig|
1051				trap(sig) do
1052					EM.defer do
1053						puts 'Shutting down gateway...'
1054						SGXbwmsgsv2.shutdown
1055
1056						puts 'Gateway has terminated.'
1057						EM.stop
1058					end
1059				end
1060			end
1061		end
1062	end
1063end