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