sgx-catapult.rb

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