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