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