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