sgx-bwmsgsv2.rb

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