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		jparams['to'].reject! { |num|
 785			num == users_num || num == others_num
 786		}
 787
 788		return [400, {}, "Missing params\n"] unless users_num && others_num
 789		return [400, {}, "Missing params\n"] unless jparams['to'].is_a?(Array)
 790
 791		puts "BODY - messageId: #{jparams['id']}" \
 792			", eventType: #{type}" \
 793			", time: #{jparams['time']}" \
 794			", direction: #{jparams['direction']}" \
 795			", deliveryState: #{jparams['deliveryState'] || 'NONE'}" \
 796			", errorCode: #{jparams['errorCode'] || 'NONE'}" \
 797			", description: #{jparams['description'] || 'NONE'}" \
 798			", tag: #{jparams['tag'] || 'NONE'}" \
 799			", media: #{jparams['media'] || 'NONE'}"
 800
 801		if others_num[0] != '+'
 802			# TODO: check that others_num actually a shortcode first
 803			others_num +=
 804				';phone-context=ca-us.phone-context.soprani.ca'
 805		end
 806
 807		bare_jid = @registration_repo.find_jid(users_num).sync
 808
 809		if !bare_jid
 810			puts "jid_key for (#{users_num}) DNE; BW API misconfigured?"
 811
 812			return [403, {}, "Customer not found\n"]
 813		end
 814
 815		msg = nil
 816		case jparams['direction']
 817		when 'in'
 818			text = ''
 819			case type
 820			when 'sms'
 821				text = jparams['text']
 822			when 'mms'
 823				has_media = false
 824
 825				if jparams['text'].empty?
 826					if not has_media
 827						text = '[suspected group msg '\
 828							'with no text (odd)]'
 829					end
 830				else
 831					text = if has_media
 832						# TODO: write/use a caption XEP
 833						jparams['text']
 834					else
 835						'[suspected group msg '\
 836						'(recipient list not '\
 837						'available) with '\
 838						'following text] ' +
 839						jparams['text']
 840					end
 841				end
 842
 843				# ie. if text param non-empty or had no media
 844				if not text.empty?
 845					msg = Blather::Stanza::Message.new(
 846						bare_jid, text)
 847					msg.from = others_num + '@' + ARGV[0]
 848					SGXbwmsgsv2.write(msg)
 849				end
 850
 851				return [200, {}, "OK"]
 852			when 'message-received'
 853				# TODO: handle group chat, and fix above
 854				text = jparams['text']
 855
 856				if jparams['to'].length > 1
 857					msg = Blather::Stanza::Message.new(
 858						Blather::JID.new(bare_jid).domain,
 859						text
 860					)
 861
 862					addrs = Nokogiri::XML::Node.new(
 863						'addresses', msg.document)
 864					addrs['xmlns'] = 'http://jabber.org/' \
 865						'protocol/address'
 866
 867					addr1 = Nokogiri::XML::Node.new(
 868						'address', msg.document)
 869					addr1['type'] = 'to'
 870					addr1['jid'] = bare_jid
 871					addrs.add_child(addr1)
 872
 873					jparams['to'].each do |receiver|
 874						addrn = Nokogiri::XML::Node.new(
 875							'address', msg.document)
 876						addrn['type'] = 'to'
 877						addrn['uri'] = "sms:#{receiver}"
 878						addrn['delivered'] = 'true'
 879						addrs.add_child(addrn)
 880					end
 881
 882					msg.add_child(addrs)
 883
 884					# TODO: delete
 885					puts "RESPONSE9: #{msg.inspect}"
 886				end
 887
 888				Array(jparams['media']).each do |media_url|
 889					unless media_url.end_with?(
 890						'.smil', '.txt', '.xml'
 891					)
 892						has_media = true
 893						SGXbwmsgsv2.send_media(
 894							others_num + '@' +
 895							ARGV[0],
 896							bare_jid, media_url,
 897							nil, nil, msg
 898						)
 899					end
 900				end
 901			else
 902				text = "unknown type (#{type})"\
 903					" with text: " + jparams['text']
 904
 905				# TODO: log/notify of this properly
 906				puts text
 907			end
 908
 909			if not msg
 910				msg = Blather::Stanza::Message.new(bare_jid, text)
 911			end
 912		else # per prior switch, this is:  jparams['direction'] == 'out'
 913			tag_parts = jparams['tag'].split(/ /, 2)
 914			id = WEBrick::HTTPUtils.unescape(tag_parts[0])
 915			resourcepart = WEBrick::HTTPUtils.unescape(tag_parts[1])
 916
 917			# TODO: remove this hack
 918			if jparams['to'].length > 1
 919				puts "WARN! group no rcpt: #{users_num}"
 920				return [200, {}, "OK"]
 921			end
 922
 923			case type
 924			when 'message-failed'
 925				# create a bare message like the one user sent
 926				msg = Blather::Stanza::Message.new(
 927					others_num + '@' + ARGV[0])
 928				msg.from = bare_jid + '/' + resourcepart
 929				msg['id'] = id
 930
 931				# TODO: add 'errorCode' and/or 'description' val
 932				# create an error reply to the bare message
 933				msg = msg.as_error(
 934					'recipient-unavailable',
 935					:wait,
 936					jparams['description']
 937				)
 938
 939				# TODO: make prettier: this should be done above
 940				others_num = params['_json'][0]['to']
 941			when 'message-delivered'
 942
 943				msg = ReceiptMessage.new(bare_jid)
 944
 945				# TODO: put in member/instance variable
 946				msg['id'] = SecureRandom.uuid
 947
 948				# TODO: send only when requested per XEP-0184
 949				rcvd = Nokogiri::XML::Node.new(
 950					'received',
 951					msg.document
 952				)
 953				rcvd['xmlns'] = 'urn:xmpp:receipts'
 954				rcvd['id'] = id
 955				msg.add_child(rcvd)
 956
 957				# TODO: make prettier: this should be done above
 958				others_num = params['_json'][0]['to']
 959			else
 960				# TODO: notify somehow of unknown state receivd?
 961				puts "message with id #{id} has "\
 962					"other type #{type}"
 963				return [200, {}, "OK"]
 964			end
 965
 966			puts "RESPONSE4: #{msg.inspect}"
 967		end
 968
 969		msg.from = others_num + '@' + ARGV[0]
 970		SGXbwmsgsv2.write(msg)
 971
 972		[200, {}, "OK"]
 973	rescue Exception => e
 974		Sentry.capture_exception(e)
 975		puts 'Shutting down gateway due to exception 013: ' + e.message
 976		SGXbwmsgsv2.shutdown
 977		puts 'Gateway has terminated.'
 978		EM.stop
 979	end
 980end
 981
 982at_exit do
 983	$stdout.sync = true
 984
 985	puts "Soprani.ca/SMS Gateway for XMPP - Bandwidth API V2\n"\
 986		"==>> last commit of this version is " + `git rev-parse HEAD` + "\n"
 987
 988	if ARGV.size != 7
 989		puts "Usage: sgx-bwmsgsv2.rb <component_jid> "\
 990			"<component_password> <server_hostname> "\
 991			"<server_port> <application_id> "\
 992			"<http_listen_port> <mms_proxy_prefix_url>"
 993		exit 0
 994	end
 995
 996	t = Time.now
 997	puts "LOG %d.%09d: starting...\n\n" % [t.to_i, t.nsec]
 998
 999	EM.run do
1000		REDIS = EM::Hiredis.connect
1001
1002		SGXbwmsgsv2.run
1003
1004		# required when using Prosody otherwise disconnects on 6-hour inactivity
1005		EM.add_periodic_timer(3600) do
1006			msg = Blather::Stanza::Iq::Ping.new(:get, 'localhost')
1007			msg.from = ARGV[0]
1008			SGXbwmsgsv2.write(msg)
1009		end
1010
1011		server = Goliath::Server.new('0.0.0.0', ARGV[5].to_i)
1012		server.api = WebhookHandler.new
1013		server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
1014		server.logger = Log4r::Logger.new('goliath')
1015		server.logger.add(Log4r::StdoutOutputter.new('console'))
1016		server.logger.level = Log4r::INFO
1017		server.start do
1018			["INT", "TERM"].each do |sig|
1019				trap(sig) do
1020					EM.defer do
1021						puts 'Shutting down gateway...'
1022						SGXbwmsgsv2.shutdown
1023
1024						puts 'Gateway has terminated.'
1025						EM.stop
1026					end
1027				end
1028			end
1029		end
1030	end
1031end unless ENV['ENV'] == 'test'