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