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