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