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