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