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		# Ensure user is registered, but discard their credentials because we don't
 545		# need them yet
 546		fetch_catapult_cred_for(i.from).then { |_creds|
 547			reply = i.reply
 548			reply.node = 'http://jabber.org/protocol/commands'
 549
 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
 558			puts 'RESPONSE_CMD_DISCO: ' + reply.inspect
 559			write_to_stream reply
 560		}.catch { |e|
 561			if e.is_a?(Array) && [2, 3].include?(e.length)
 562				write_to_stream i.as_error(e[1], e[0], e[2])
 563			else
 564				EMPromise.reject(e)
 565			end
 566		}
 567	end
 568
 569	iq '/iq/ns:query', ns:	'http://jabber.org/protocol/disco#info' do |i|
 570		# TODO: return error if i.type is :set - if it is :reply or
 571		#  :error it should be ignored (as the below does currently);
 572		#  review specification to see how to handle other type values
 573		if i.type != :get
 574			puts 'DISCO iq rcvd, of non-get type "' + i.type.to_s +
 575				'" for message "' + i.inspect + '"; ignoring...'
 576			next
 577		end
 578
 579		# respond to capabilities request for an sgx-bwmsgsv2 number JID
 580		if i.to.node
 581			# TODO: confirm the node URL is expected using below
 582			#puts "XR[node]: #{xpath_result[0]['node']}"
 583
 584			msg = i.reply
 585			msg.node = i.node
 586			msg.identities = user_cap_identities
 587			msg.features = user_cap_features
 588
 589			puts 'RESPONSE7: ' + msg.inspect
 590			write_to_stream msg
 591			next
 592		end
 593
 594		# respond to capabilities request for sgx-bwmsgsv2 itself
 595		msg = i.reply
 596		msg.node = i.node
 597		msg.identities = [{
 598			name: 'Soprani.ca Gateway to XMPP - Bandwidth API V2',
 599			type: 'sms', category: 'gateway'
 600		}]
 601		msg.features = @gateway_features
 602		write_to_stream msg
 603	end
 604
 605	def self.check_then_register(i, *creds)
 606		@registration_repo
 607			.put(i.from, *creds)
 608			.catch_only(RegistrationRepo::Conflict) { |e|
 609				EMPromise.reject([:cancel, 'conflict', e.message])
 610			}.then {
 611				write_to_stream i.reply
 612			}
 613	end
 614
 615	def self.creds_from_registration_query(i)
 616		if i.query.find_first("./ns:x", ns: "jabber:x:data")
 617			[
 618				i.form.field("nick")&.value,
 619				i.form.field("username")&.value,
 620				i.form.field("password")&.value,
 621				i.form.field("phone")&.value
 622			]
 623		else
 624			[i.nick, i.username, i.password, i.phone]
 625		end
 626	end
 627
 628	def self.process_registration(i)
 629		EMPromise.resolve(nil).then {
 630			if i.remove?
 631				@registration_repo.delete(i.from).then do
 632					write_to_stream i.reply
 633					EMPromise.reject(:done)
 634				end
 635			else
 636				creds_from_registration_query(i)
 637			end
 638		}.then { |user_id, api_token, api_secret, phone_num|
 639			if phone_num && phone_num[0] == '+'
 640				[user_id, api_token, api_secret, phone_num]
 641			else
 642				# TODO: add text re number not (yet) supported
 643				EMPromise.reject([:cancel, 'item-not-found'])
 644			end
 645		}.then { |user_id, api_token, api_secret, phone_num|
 646			# TODO: find way to verify #{phone_num}, too
 647			call_catapult(
 648				api_token,
 649				api_secret,
 650				:get,
 651				"api/v2/users/#{user_id}/media"
 652			).then { |response|
 653				JSON.parse(response)
 654				# TODO: confirm response is array - could be empty
 655
 656				puts "register got str #{response.to_s[0..999]}"
 657
 658				check_then_register(
 659					i,
 660					user_id,
 661					api_token,
 662					api_secret,
 663					phone_num
 664				)
 665			}
 666		}.catch_only(BandwidthError) { |e|
 667			EMPromise.reject(case e.code
 668			when 401
 669				# TODO: add text re bad credentials
 670				[:auth, 'not-authorized']
 671			when 404
 672				# TODO: add text re number not found or disabled
 673				[:cancel, 'item-not-found']
 674			else
 675				[:modify, 'not-acceptable']
 676			end)
 677		}
 678	end
 679
 680	def self.registration_form(orig, existing_number=nil)
 681		orig.registered = !!existing_number
 682
 683		# TODO: update "User Id" x2 below (to "accountId"?), and others?
 684		orig.instructions = "Enter the information from your Account "\
 685			"page as well as the Phone Number\nin your "\
 686			"account you want to use (ie. '+12345678901')"\
 687			".\nUser Id is nick, API Token is username, "\
 688			"API Secret is password, Phone Number is phone"\
 689			".\n\nThe source code for this gateway is at "\
 690			"https://gitlab.com/soprani.ca/sgx-bwmsgsv2 ."\
 691			"\nCopyright (C) 2017-2020  Denver Gingerich "\
 692			"and others, licensed under AGPLv3+."
 693		orig.nick = ""
 694		orig.username = ""
 695		orig.password = ""
 696		orig.phone = existing_number.to_s
 697
 698		orig.form.fields = [
 699			{
 700				required: true, type: :"text-single",
 701				label: 'User Id', var: 'nick'
 702			},
 703			{
 704				required: true, type: :"text-single",
 705				label: 'API Token', var: 'username'
 706			},
 707			{
 708				required: true, type: :"text-private",
 709				label: 'API Secret', var: 'password'
 710			},
 711			{
 712				required: true, type: :"text-single",
 713				label: 'Phone Number', var: 'phone',
 714				value: existing_number.to_s
 715			}
 716		]
 717		orig.form.title = 'Register for '\
 718			'Soprani.ca Gateway to XMPP - Bandwidth API V2'
 719		orig.form.instructions = "Enter the details from your Account "\
 720			"page as well as the Phone Number\nin your "\
 721			"account you want to use (ie. '+12345678901')"\
 722			".\n\nThe source code for this gateway is at "\
 723			"https://gitlab.com/soprani.ca/sgx-bwmsgsv2 ."\
 724			"\nCopyright (C) 2017-2020  Denver Gingerich "\
 725			"and others, licensed under AGPLv3+."
 726
 727		orig
 728	end
 729
 730	ibr do |i|
 731		puts "IQ: #{i.inspect}"
 732
 733		case i.type
 734		when :set
 735			process_registration(i)
 736		when :get
 737			bare_jid = i.from.stripped
 738			@registration_repo.find(bare_jid).then { |creds|
 739				reply = registration_form(i.reply, creds.last)
 740				puts "RESPONSE2: #{reply.inspect}"
 741				write_to_stream reply
 742			}
 743		else
 744			# Unknown IQ, ignore for now
 745			EMPromise.reject(:done)
 746		end.catch { |e|
 747			if e.is_a?(Array) && (e.length == 2 || e.length == 3)
 748				write_to_stream i.as_error(e[1], e[0], e[2])
 749			elsif e != :done
 750				EMPromise.reject(e)
 751			end
 752		}.catch(&method(:panic))
 753	end
 754
 755	command :execute?, node: "set-port-out-pin", sessionid: nil do |iq|
 756		# Ensure user is registered, but discard their credentials because we don't
 757		# need them yet
 758		fetch_catapult_cred_for(iq.from).then { |_creds|
 759			reply = iq.reply
 760			reply.node = 'set-port-out-pin'
 761			reply.sessionid = SecureRandom.uuid
 762			reply.status = :executing
 763
 764			form = Blather::Stanza::X.find_or_create(reply.command)
 765			form.type = "form"
 766			form.fields = [
 767				{
 768					var: 'pin',
 769					type: 'text-private',
 770					label: 'Port-Out PIN',
 771					required: true
 772				},
 773				{
 774					var: 'confirm_pin',
 775					type: 'text-private',
 776					label: 'Confirm PIN',
 777					required: true
 778				}
 779			]
 780
 781			reply.command.add_child(form)
 782			reply.allowed_actions = [:complete]
 783
 784			puts "RESPONSE_CMD_FORM: #{reply.inspect}"
 785			write_to_stream reply
 786		}.catch { |e|
 787			if e.is_a?(Array) && [2, 3].include?(e.length)
 788				write_to_stream iq.as_error(e[1], e[0], e[2])
 789			else
 790			EMPromise.reject(e)
 791			end
 792		}.catch(&method(:panic))
 793	end
 794
 795	command :complete?, node: "set-port-out-pin", sessionid: /./ do |iq|
 796		pin = iq.form.field('pin')&.value
 797		confirm_pin = iq.form.field('confirm_pin')&.value
 798
 799		if pin.nil? || confirm_pin.nil?
 800			write_to_stream iq.as_error(
 801				'bad-request',
 802				:modify,
 803				'PIN fields are required'
 804			)
 805			next
 806		end
 807
 808		if pin != confirm_pin
 809			write_to_stream iq.as_error(
 810				'bad-request',
 811				:modify,
 812				'PIN confirmation does not match'
 813			)
 814			next
 815		end
 816
 817		if pin !~ /\A[a-zA-Z0-9]{4,10}\z/
 818			write_to_stream iq.as_error(
 819				'bad-request',
 820				:modify,
 821				'PIN must be 4-10 alphanumeric characters'
 822			)
 823			next
 824		end
 825
 826		fetch_catapult_cred_for(iq.from).then { |creds|
 827			user_id, token, secret, phone_num = creds
 828
 829			# Stripping +1 like this feels janky, but Bandwidth only deals in +1
 830			# numbers and this is a Bandwidth SGX, so it's fine.
 831			phone_num_local = phone_num.sub(/^\+1/, '')
 832			BandwidthTNOptions.set_port_out_pin(user_id, token, secret, pin, phone_num_local).then {
 833				reply = iq.reply
 834				reply.node = 'set-port-out-pin'
 835				reply.sessionid = iq.sessionid
 836				reply.status = :completed
 837				reply.note_type = :info
 838				reply.note_text = 'Port-out PIN has been set successfully.'
 839
 840				write_to_stream reply
 841			}.catch { |e|
 842				reply = iq.reply
 843				reply.node = 'set-port-out-pin'
 844				reply.sessionid = iq.sessionid
 845				reply.status = :completed
 846				reply.note_type = :error
 847				error_msg = if e.respond_to?(:message) && e.message.include?('not valid')
 848					"Invalid phone number format. "\
 849					"Please check your registered phone number."
 850				elsif e.respond_to?(:message) && e.message.include?('ErrorCode')
 851					"Bandwidth API error: #{e.message}"
 852				else
 853					"Failed to set port-out PIN. Please try again later."
 854				end
 855				reply.note_text = error_msg
 856
 857				write_to_stream reply
 858			}
 859		}.catch { |e|
 860			if e.is_a?(Array) && [2, 3].include?(e.length)
 861				write_to_stream iq.as_error(e[1], e[0], e[2])
 862			else
 863				EMPromise.reject(e)
 864			end
 865		}.catch(&method(:panic))
 866	end
 867
 868	iq type: [:get, :set] do |iq|
 869		write_to_stream(Blather::StanzaError.new(
 870			iq,
 871			'feature-not-implemented',
 872			:cancel
 873		))
 874	end
 875end
 876
 877class ReceiptMessage < Blather::Stanza
 878	def self.new(to=nil)
 879		node = super :message
 880		node.to = to
 881		node
 882	end
 883end
 884
 885class WebhookHandler < Goliath::API
 886	use Sentry::Rack::CaptureExceptions
 887	use Goliath::Rack::Params
 888
 889	def response(env)
 890		@registration_repo = RegistrationRepo.new
 891		# TODO: add timestamp grab here, and MUST include ./tai version
 892
 893		puts 'ENV: ' + env.reject { |k| k == 'params' }.to_s
 894
 895		if params.empty?
 896			puts 'PARAMS empty!'
 897			return [200, {}, "OK"]
 898		end
 899
 900		if env['REQUEST_URI'] != '/'
 901			puts 'BADREQUEST1: non-/ request "' +
 902				env['REQUEST_URI'] + '", method "' +
 903				env['REQUEST_METHOD'] + '"'
 904			return [200, {}, "OK"]
 905		end
 906
 907		if env['REQUEST_METHOD'] != 'POST'
 908			puts 'BADREQUEST2: non-POST request; URI: "' +
 909				env['REQUEST_URI'] + '", method "' +
 910				env['REQUEST_METHOD'] + '"'
 911			return [200, {}, "OK"]
 912		end
 913
 914		# TODO: process each message in list, not just first one
 915		jparams = params.dig('_json', 0, 'message')
 916		type = params.dig('_json', 0, 'type')
 917
 918		return [400, {}, "Missing params\n"] unless jparams && type
 919
 920		users_num, others_num = if jparams['direction'] == 'in'
 921			[jparams['owner'], jparams['from']]
 922		elsif jparams['direction'] == 'out'
 923			[jparams['from'], jparams['owner']]
 924		else
 925			puts "big prob: '#{jparams['direction']}'"
 926			return [400, {}, "OK"]
 927		end
 928
 929		jparams['to'].reject! { |num|
 930			num == users_num || num == others_num
 931		}
 932
 933		return [400, {}, "Missing params\n"] unless users_num && others_num
 934		return [400, {}, "Missing params\n"] unless jparams['to'].is_a?(Array)
 935
 936		puts "BODY - messageId: #{jparams['id']}" \
 937			", eventType: #{type}" \
 938			", time: #{jparams['time']}" \
 939			", direction: #{jparams['direction']}" \
 940			", deliveryState: #{jparams['deliveryState'] || 'NONE'}" \
 941			", errorCode: #{jparams['errorCode'] || 'NONE'}" \
 942			", description: #{jparams['description'] || 'NONE'}" \
 943			", tag: #{jparams['tag'] || 'NONE'}" \
 944			", media: #{jparams['media'] || 'NONE'}"
 945
 946		if others_num[0] != '+'
 947			# TODO: check that others_num actually a shortcode first
 948			others_num +=
 949				';phone-context=ca-us.phone-context.soprani.ca'
 950		end
 951
 952		bare_jid = @registration_repo.find_jid(users_num).sync
 953
 954		if !bare_jid
 955			puts "jid_key for (#{users_num}) DNE; BW API misconfigured?"
 956
 957			return [403, {}, "Customer not found\n"]
 958		end
 959
 960		msg = nil
 961		case jparams['direction']
 962		when 'in'
 963			text = ''
 964			case type
 965			when 'sms'
 966				text = jparams['text']
 967			when 'mms'
 968				has_media = false
 969
 970				if jparams['text'].empty?
 971					if not has_media
 972						text = '[suspected group msg '\
 973							'with no text (odd)]'
 974					end
 975				else
 976					text = if has_media
 977						# TODO: write/use a caption XEP
 978						jparams['text']
 979					else
 980						'[suspected group msg '\
 981						'(recipient list not '\
 982						'available) with '\
 983						'following text] ' +
 984						jparams['text']
 985					end
 986				end
 987
 988				# ie. if text param non-empty or had no media
 989				if not text.empty?
 990					msg = Blather::Stanza::Message.new(
 991						bare_jid, text)
 992					msg.from = others_num + '@' + ARGV[0]
 993					SGXbwmsgsv2.write(msg)
 994				end
 995
 996				return [200, {}, "OK"]
 997			when 'message-received'
 998				# TODO: handle group chat, and fix above
 999				text = jparams['text']
1000
1001				if jparams['to'].length > 1
1002					msg = Blather::Stanza::Message.new(
1003						Blather::JID.new(bare_jid).domain,
1004						text
1005					)
1006
1007					addrs = Nokogiri::XML::Node.new(
1008						'addresses', msg.document)
1009					addrs['xmlns'] = 'http://jabber.org/' \
1010						'protocol/address'
1011
1012					addr1 = Nokogiri::XML::Node.new(
1013						'address', msg.document)
1014					addr1['type'] = 'to'
1015					addr1['jid'] = bare_jid
1016					addrs.add_child(addr1)
1017
1018					jparams['to'].each do |receiver|
1019						addrn = Nokogiri::XML::Node.new(
1020							'address', msg.document)
1021						addrn['type'] = 'to'
1022						addrn['uri'] = "sms:#{receiver}"
1023						addrn['delivered'] = 'true'
1024						addrs.add_child(addrn)
1025					end
1026
1027					msg.add_child(addrs)
1028
1029					# TODO: delete
1030					puts "RESPONSE9: #{msg.inspect}"
1031				end
1032
1033				Array(jparams['media']).each do |media_url|
1034					unless media_url.end_with?(
1035						'.smil', '.txt', '.xml'
1036					)
1037						has_media = true
1038						SGXbwmsgsv2.send_media(
1039							others_num + '@' +
1040							ARGV[0],
1041							bare_jid, media_url,
1042							nil, nil, msg
1043						)
1044					end
1045				end
1046			else
1047				text = "unknown type (#{type})"\
1048					" with text: " + jparams['text']
1049
1050				# TODO: log/notify of this properly
1051				puts text
1052			end
1053
1054			if not msg
1055				msg = Blather::Stanza::Message.new(bare_jid, text)
1056			end
1057		else # per prior switch, this is:  jparams['direction'] == 'out'
1058			tag_parts = jparams['tag'].split(/ /, 2)
1059			id = WEBrick::HTTPUtils.unescape(tag_parts[0])
1060			resourcepart = WEBrick::HTTPUtils.unescape(tag_parts[1])
1061
1062			# TODO: remove this hack
1063			if jparams['to'].length > 1
1064				puts "WARN! group no rcpt: #{users_num}"
1065				return [200, {}, "OK"]
1066			end
1067
1068			case type
1069			when 'message-failed'
1070				# create a bare message like the one user sent
1071				msg = Blather::Stanza::Message.new(
1072					others_num + '@' + ARGV[0])
1073				msg.from = bare_jid + '/' + resourcepart
1074				msg['id'] = id
1075
1076				# TODO: add 'errorCode' and/or 'description' val
1077				# create an error reply to the bare message
1078				msg = msg.as_error(
1079					'recipient-unavailable',
1080					:wait,
1081					jparams['description']
1082				)
1083
1084				# TODO: make prettier: this should be done above
1085				others_num = params['_json'][0]['to']
1086			when 'message-delivered'
1087
1088				msg = ReceiptMessage.new(bare_jid)
1089
1090				# TODO: put in member/instance variable
1091				msg['id'] = SecureRandom.uuid
1092
1093				# TODO: send only when requested per XEP-0184
1094				rcvd = Nokogiri::XML::Node.new(
1095					'received',
1096					msg.document
1097				)
1098				rcvd['xmlns'] = 'urn:xmpp:receipts'
1099				rcvd['id'] = id
1100				msg.add_child(rcvd)
1101
1102				# TODO: make prettier: this should be done above
1103				others_num = params['_json'][0]['to']
1104			else
1105				# TODO: notify somehow of unknown state receivd?
1106				puts "message with id #{id} has "\
1107					"other type #{type}"
1108				return [200, {}, "OK"]
1109			end
1110
1111			puts "RESPONSE4: #{msg.inspect}"
1112		end
1113
1114		msg.from = others_num + '@' + ARGV[0]
1115		SGXbwmsgsv2.write(msg)
1116
1117		[200, {}, "OK"]
1118	rescue Exception => e
1119		Sentry.capture_exception(e)
1120		puts 'Shutting down gateway due to exception 013: ' + e.message
1121		SGXbwmsgsv2.shutdown
1122		puts 'Gateway has terminated.'
1123		EM.stop
1124	end
1125end
1126
1127at_exit do
1128	$stdout.sync = true
1129
1130	puts "Soprani.ca/SMS Gateway for XMPP - Bandwidth API V2\n"\
1131		"==>> last commit of this version is " + `git rev-parse HEAD` + "\n"
1132
1133	if ARGV.size != 7
1134		puts "Usage: sgx-bwmsgsv2.rb <component_jid> "\
1135			"<component_password> <server_hostname> "\
1136			"<server_port> <application_id> "\
1137			"<http_listen_port> <mms_proxy_prefix_url>"
1138		exit 0
1139	end
1140
1141	t = Time.now
1142	puts "LOG %d.%09d: starting...\n\n" % [t.to_i, t.nsec]
1143
1144	EM.run do
1145		REDIS = EM::Hiredis.connect
1146
1147		SGXbwmsgsv2.run
1148
1149		# required when using Prosody otherwise disconnects on 6-hour inactivity
1150		EM.add_periodic_timer(3600) do
1151			msg = Blather::Stanza::Iq::Ping.new(:get, 'localhost')
1152			msg.from = ARGV[0]
1153			SGXbwmsgsv2.write(msg)
1154		end
1155
1156		server = Goliath::Server.new('0.0.0.0', ARGV[5].to_i)
1157		server.api = WebhookHandler.new
1158		server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
1159		server.logger = Log4r::Logger.new('goliath')
1160		server.logger.add(Log4r::StdoutOutputter.new('console'))
1161		server.logger.level = Log4r::INFO
1162		server.start do
1163			["INT", "TERM"].each do |sig|
1164				trap(sig) do
1165					EM.defer do
1166						puts 'Shutting down gateway...'
1167						SGXbwmsgsv2.shutdown
1168
1169						puts 'Gateway has terminated.'
1170						EM.stop
1171					end
1172				end
1173			end
1174		end
1175	end
1176end unless ENV['ENV'] == 'test'