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