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