sgx-catapult.rb

  1#!/usr/bin/env ruby
  2#
  3# Copyright (C) 2017  Denver Gingerich <denver@ossguy.com>
  4# Copyright (C) 2017  Stephen Paul Weber <singpolyma@singpolyma.net>
  5#
  6# This file is part of sgx-catapult.
  7#
  8# sgx-catapult 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-catapult 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-catapult.  If not, see <http://www.gnu.org/licenses/>.
 20
 21require 'blather/client/dsl'
 22require 'json'
 23require 'net/http'
 24require 'redis/connection/hiredis'
 25require 'uri'
 26
 27require 'goliath/api'
 28require 'goliath/server'
 29require 'log4r'
 30
 31if ARGV.size != 8 then
 32	puts "Usage: sgx-catapult.rb <component_jid> <component_password> " +
 33		"<server_hostname> <server_port> " +
 34		"<redis_hostname> <redis_port> <delivery_receipt_url> " +
 35		"<http_listen_port>"
 36	exit 0
 37end
 38
 39module SGXcatapult
 40	extend Blather::DSL
 41
 42	def self.run
 43		client.run
 44	end
 45
 46	# so classes outside this module can write messages, too
 47	def self.write(stanza)
 48		client.write(stanza)
 49	end
 50
 51	def self.error_msg(orig, query_node, type, name, text = nil)
 52		orig.add_child(query_node)
 53		orig.type = :error
 54
 55		error = Nokogiri::XML::Node.new 'error', orig.document
 56		error['type'] = type
 57		orig.add_child(error)
 58
 59		suberr = Nokogiri::XML::Node.new name, orig.document
 60		suberr['xmlns'] = 'urn:ietf:params:xml:ns:xmpp-stanzas'
 61		error.add_child(suberr)
 62
 63		# TODO: add some explanatory xml:lang='en' text (see text param)
 64		puts "RESPONSE3: #{orig.inspect}"
 65		return orig
 66	end
 67
 68	setup ARGV[0], ARGV[1], ARGV[2], ARGV[3]
 69
 70	message :chat?, :body do |m|
 71		num_dest = m.to.to_s.split('@', 2)[0]
 72
 73		if num_dest[0] != '+'
 74			# TODO: add text re number not (yet) supported/implmnted
 75			write_to_stream error_msg(m.reply, m.body, :modify,
 76				'policy-violation')
 77			next
 78		end
 79
 80		bare_jid = m.from.to_s.split('/', 2)[0]
 81		cred_key = "catapult_cred-" + bare_jid
 82
 83		conn = Hiredis::Connection.new
 84		conn.connect(ARGV[4], ARGV[5].to_i)
 85
 86		conn.write ["EXISTS", cred_key]
 87		if conn.read == 0
 88			conn.disconnect
 89
 90			# TODO: add text re credentials not being registered
 91			write_to_stream error_msg(m.reply, m.body, :auth,
 92				'registration-required')
 93			next
 94		end
 95
 96		conn.write ["LRANGE", cred_key, 0, 3]
 97		creds = conn.read
 98		conn.disconnect
 99
100		uri = URI.parse('https://api.catapult.inetwork.com')
101		http = Net::HTTP.new(uri.host, uri.port)
102		http.use_ssl = true
103		request = Net::HTTP::Post.new('/v1/users/' + creds[0] +
104			'/messages')
105		request.basic_auth creds[1], creds[2]
106		request.add_field('Content-Type', 'application/json')
107		request.body = JSON.dump({
108			'from'			=> creds[3],
109			'to'			=> num_dest,
110			'text'			=> m.body,
111			'tag'			=> m.id, # TODO: message has it?
112			'receiptRequested'	=> 'all',
113			'callbackUrl'		=> ARGV[6]
114		})
115		response = http.request(request)
116
117		puts 'API response to send: ' + response.to_s + ' with code ' +
118			response.code + ', body "' + response.body + '"'
119
120		if response.code != '201'
121			# TODO: add text re unexpected code; mention code number
122			write_to_stream error_msg(m.reply, m.body, :cancel,
123				'internal-server-error')
124			next
125		end
126
127		# TODO: don't echo message; leave in until we rcv msgs properly
128		begin
129			puts "#{m.from.to_s} -> #{m.to.to_s} #{m.body}"
130			msg = Blather::Stanza::Message.new(m.from, 'thx for "' +
131				m.body + '"')
132			msg.from = m.to
133			write_to_stream msg
134		rescue => e
135			# TODO: do something better with this info
136			say m.from, e.inspect
137		end
138	end
139
140	iq '/iq/ns:query', :ns =>
141		'http://jabber.org/protocol/disco#items' do |i, xpath_result|
142
143		write_to_stream i.reply
144	end
145
146	iq '/iq/ns:query', :ns =>
147		'http://jabber.org/protocol/disco#info' do |i, xpath_result|
148
149		msg = i.reply
150		msg.identities = [{:name =>
151			'Soprani.ca Gateway to XMPP - Catapult',
152			:type => 'sms-ctplt', :category => 'gateway'}]
153		msg.features = ["jabber:iq:register",
154			"jabber:iq:gateway", "jabber:iq:private",
155			"http://jabber.org/protocol/disco#info",
156			"http://jabber.org/protocol/commands",
157			"http://jabber.org/protocol/muc"]
158		write_to_stream msg
159	end
160
161	iq '/iq/ns:query', :ns => 'jabber:iq:register' do |i, qn|
162		puts "IQ: #{i.inspect}"
163
164		if i.type == :set
165			xn = qn.children.find { |v| v.element_name == "x" }
166
167			user_id = ''
168			api_token = ''
169			api_secret = ''
170			phone_num = ''
171
172			if xn.nil?
173				user_id = qn.children.find {
174					|v| v.element_name == "nick" }
175				api_token = qn.children.find {
176					|v| v.element_name == "username" }
177				api_secret = qn.children.find {
178					|v| v.element_name == "password" }
179				phone_num = qn.children.find {
180					|v| v.element_name == "phone" }
181			else
182				for field in xn.children
183					if field.element_name == "field"
184						val = field.children.find { |v|
185						v.element_name == "value" }
186
187						case field['var']
188						when 'nick'
189							user_id = val.text
190						when 'username'
191							api_token = val.text
192						when 'password'
193							api_secret = val.text
194						when 'phone'
195							phone_num = val.text
196						else
197							# TODO: error
198							puts "?: " +field['var']
199						end
200					end
201				end
202			end
203
204			if phone_num[0] != '+'
205				# TODO: add text re number not (yet) supported
206				write_to_stream error_msg(i.reply, qn, :modify,
207					'policy-violation')
208				next
209			end
210
211			uri = URI.parse('https://api.catapult.inetwork.com')
212			http = Net::HTTP.new(uri.host, uri.port)
213			http.use_ssl = true
214			request = Net::HTTP::Get.new('/v1/users/' + user_id +
215				'/phoneNumbers/' + phone_num)
216			request.basic_auth api_token, api_secret
217			response = http.request(request)
218
219			puts 'API response: ' + response.to_s + ' with code ' +
220				response.code + ', body "' + response.body + '"'
221
222			if response.code == '200'
223				params = JSON.parse response.body
224				if params['numberState'] == 'enabled'
225					num_key = "catapult_num-" + phone_num
226
227					bare_jid = i.from.to_s.split('/', 2)[0]
228					cred_key = "catapult_cred-" + bare_jid
229
230					# TODO: pre-validate ARGV[5] is integer
231					conn = Hiredis::Connection.new
232					conn.connect(ARGV[4], ARGV[5].to_i)
233
234					conn.write ["EXISTS", num_key]
235					if conn.read == 1
236						conn.disconnect
237
238						# TODO: add txt re num exists
239						write_to_stream error_msg(
240							i.reply, qn, :cancel,
241							'conflict')
242						next
243					end
244
245					conn.write ["EXISTS", cred_key]
246					if conn.read == 1
247						conn.disconnect
248
249						# TODO: add txt re already exist
250						write_to_stream error_msg(
251							i.reply, qn, :cancel,
252							'conflict')
253						next
254					end
255
256					conn.write ["RPUSH",num_key,bare_jid]
257					if conn.read != 1
258						conn.disconnect
259
260						# TODO: catch/relay RuntimeError
261						# TODO: add txt re push failure
262						write_to_stream error_msg(
263							i.reply, qn, :cancel,
264							'internal-server-error')
265						next
266					end
267
268					conn.write ["RPUSH",cred_key,user_id]
269					conn.write ["RPUSH",cred_key,api_token]
270					conn.write ["RPUSH",cred_key,api_secret]
271					conn.write ["RPUSH",cred_key,phone_num]
272
273					for n in 1..4 do
274						# TODO: catch/relay RuntimeError
275						result = conn.read
276						if result != n
277							conn.disconnect
278
279							write_to_stream(
280							error_msg(
281							i.reply, qn, :cancel,
282							'internal-server-error')
283							)
284							next
285						end
286					end
287					conn.disconnect
288
289					write_to_stream i.reply
290				else
291					# TODO: add text re number disabled
292					write_to_stream error_msg(i.reply, qn,
293						:modify, 'not-acceptable')
294				end
295			elsif response.code == '401'
296				# TODO: add text re bad credentials
297				write_to_stream error_msg(i.reply, qn, :auth,
298					'not-authorized')
299			elsif response.code == '404'
300				# TODO: add text re number not found or disabled
301				write_to_stream error_msg(i.reply, qn, :cancel,
302					'item-not-found')
303			else
304				# TODO: add text re misc error, and mention code
305				write_to_stream error_msg(i.reply, qn, :modify,
306					'not-acceptable')
307			end
308
309		elsif i.type == :get
310			orig = i.reply
311
312			msg = Nokogiri::XML::Node.new 'query',orig.document
313			msg['xmlns'] = 'jabber:iq:register'
314			n1 = Nokogiri::XML::Node.new 'instructions',msg.document
315			n1.content= "Enter the information from your Account " +
316				"page as well as the Phone Number\nin your " +
317				"account you want to use (ie. '+12345678901')" +
318				".\nUser Id is nick, API Token is username, " +
319				"API Secret is password, Phone Number is phone"+
320				".\n\nThe source code for this gateway is at " +
321				"https://github.com/ossguy/sgx-catapult ." +
322				"\nCopyright (C) 2017  Denver Gingerich, " +
323				"licensed under AGPLv3+."
324			n2 = Nokogiri::XML::Node.new 'nick',msg.document
325			n3 = Nokogiri::XML::Node.new 'username',msg.document
326			n4 = Nokogiri::XML::Node.new 'password',msg.document
327			n5 = Nokogiri::XML::Node.new 'phone',msg.document
328			msg.add_child(n1)
329			msg.add_child(n2)
330			msg.add_child(n3)
331			msg.add_child(n4)
332			msg.add_child(n5)
333
334			x = Blather::Stanza::X.new :form, [
335				{:required => true, :type => :"text-single",
336				:label => 'User Id', :var => 'nick'},
337				{:required => true, :type => :"text-single",
338				:label => 'API Token', :var => 'username'},
339				{:required => true, :type => :"text-private",
340				:label => 'API Secret', :var => 'password'},
341				{:required => true, :type => :"text-single",
342				:label => 'Phone Number', :var => 'phone'}
343			]
344			x.title= 'Register for ' +
345				'Soprani.ca Gateway to XMPP - Catapult'
346			x.instructions= "Enter the details from your Account " +
347				"page as well as the Phone Number\nin your " +
348				"account you want to use (ie. '+12345678901')" +
349				".\n\nThe source code for this gateway is at " +
350				"https://github.com/ossguy/sgx-catapult ." +
351				"\nCopyright (C) 2017  Denver Gingerich, " +
352				"licensed under AGPLv3+."
353			msg.add_child(x)
354
355			orig.add_child(msg)
356			puts "RESPONSE2: #{orig.inspect}"
357			write_to_stream orig
358			puts "SENT"
359		end
360	end
361
362	subscription(:request?) do |s|
363		# TODO: are these the best to return?  really need '!' here?
364		#write_to_stream s.approve!
365		#write_to_stream s.request!
366	end
367end
368
369[:INT, :TERM].each do |sig|
370	trap(sig) {
371		puts 'Shutting down gateway...'
372		SGXcatapult.shutdown
373		puts 'Gateway has terminated.'
374
375		EM.stop
376	}
377end
378
379class WebhookHandler < Goliath::API
380	def response(env)
381		msg = Blather::Stanza::Message.new('test@localhost', 'hi')
382		SGXcatapult.write(msg)
383
384		[200, {}, "OK"]
385	end
386end
387
388EM.run do
389	SGXcatapult.run
390
391	server = Goliath::Server.new('127.0.0.1', ARGV[7].to_i)
392	server.api = WebhookHandler.new
393	server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
394	server.logger = Log4r::Logger.new('goliath')
395	server.logger.add(Log4r::StdoutOutputter.new('console'))
396	server.logger.level = Log4r::INFO
397	server.start
398end