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