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