sgx-bwmsgsv2.rb

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