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