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