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 'time'
26require 'uri'
27require 'uuid'
28require 'webrick'
29
30require 'goliath/api'
31require 'goliath/server'
32require 'log4r'
33
34$stdout.sync = true
35
36puts "Soprani.ca/SMS Gateway for XMPP - Catapult v0.026\n\n"
37
38if ARGV.size != 9 then
39 puts "Usage: sgx-catapult.rb <component_jid> <component_password> " +
40 "<server_hostname> <server_port> " +
41 "<redis_hostname> <redis_port> <delivery_receipt_url> " +
42 "<http_listen_port> <mms_proxy_prefix_url>"
43 exit 0
44end
45
46t = Time.now
47puts "LOG %d.%09d: starting...\n\n" % [t.to_i, t.nsec]
48
49module SGXcatapult
50 extend Blather::DSL
51
52 @jingle_sids = Hash.new
53 @jingle_fnames = Hash.new
54 @partial_data = Hash.new
55 @uuid_gen = UUID.new
56
57 def self.run
58 client.run
59 end
60
61 # so classes outside this module can write messages, too
62 def self.write(stanza)
63 client.write(stanza)
64 end
65
66 def self.error_msg(orig, query_node, type, name, text = nil)
67 if not query_node.nil?
68 orig.add_child(query_node)
69 orig.type = :error
70 end
71
72 error = Nokogiri::XML::Node.new 'error', orig.document
73 error['type'] = type
74 orig.add_child(error)
75
76 suberr = Nokogiri::XML::Node.new name, orig.document
77 suberr['xmlns'] = 'urn:ietf:params:xml:ns:xmpp-stanzas'
78 error.add_child(suberr)
79
80 # TODO: add some explanatory xml:lang='en' text (see text param)
81 puts "RESPONSE3: #{orig.inspect}"
82 return orig
83 end
84
85 setup ARGV[0], ARGV[1], ARGV[2], ARGV[3]
86
87 message :chat?, :body do |m|
88 num_dest = m.to.to_s.split('@', 2)[0]
89
90 if num_dest[0] != '+'
91 # check to see if a valid shortcode context is specified
92 num_and_context = num_dest.split(';', 2)
93 if num_and_context[1] and num_and_context[1] ==
94 'phone-context=ca-us.phone-context.soprani.ca'
95
96 # TODO: check if num_dest is fully numeric
97 num_dest = num_and_context[0]
98 else
99 # TODO: text re num not (yet) supportd/implmentd
100 write_to_stream error_msg(m.reply, m.body,
101 :cancel, 'item-not-found')
102 next
103 end
104 end
105
106 bare_jid = m.from.to_s.split('/', 2)[0]
107 cred_key = "catapult_cred-" + bare_jid
108
109 conn = Hiredis::Connection.new
110 conn.connect(ARGV[4], ARGV[5].to_i)
111
112 conn.write ["EXISTS", cred_key]
113 if conn.read == 0
114 conn.disconnect
115
116 # TODO: add text re credentials not being registered
117 write_to_stream error_msg(m.reply, m.body, :auth,
118 'registration-required')
119 next
120 end
121
122 conn.write ["LRANGE", cred_key, 0, 3]
123 user_id, api_token, api_secret, users_num = conn.read
124
125 # if the destination user is in the system just pass on directly
126 jid_key = "catapult_jid-" + num_dest
127 conn.write ["EXISTS", jid_key]
128 if conn.read > 0
129 # setup delivery receipt; sort of a reply but not quite
130 rcpt = ReceiptMessage.new(bare_jid)
131 rcpt.from = m.to
132
133 # pass on the original message (before sending receipt)
134 conn.write ["GET", jid_key]
135 m.to = conn.read
136
137 m.from = users_num + '@' + ARGV[0]
138
139 puts 'XRESPONSE0: ' + m.inspect
140 write_to_stream m
141
142 # send a delivery receipt back to the sender
143 # TODO: send only when requested per XEP-0184
144
145 # TODO: put in member/instance variable
146 uuid_gen = UUID.new
147 rcpt['id'] = uuid_gen.generate
148 rcvd = Nokogiri::XML::Node.new 'received', rcpt.document
149 rcvd['xmlns'] = 'urn:xmpp:receipts'
150 rcvd['id'] = m.id
151 rcpt.add_child(rcvd)
152
153 puts 'XRESPONSE1: ' + rcpt.inspect
154 write_to_stream rcpt
155
156 conn.disconnect
157 next
158 end
159
160 conn.disconnect
161
162 uri = URI.parse('https://api.catapult.inetwork.com')
163 http = Net::HTTP.new(uri.host, uri.port)
164 http.use_ssl = true
165 request = Net::HTTP::Post.new('/v1/users/' + user_id +
166 '/messages')
167 request.basic_auth api_token, api_secret
168 request.add_field('Content-Type', 'application/json')
169 request.body = JSON.dump({
170 'from' => users_num,
171 'to' => num_dest,
172 'text' => m.body,
173 'tag' =>
174 # callbacks need both the id and resourcepart
175 WEBrick::HTTPUtils.escape(m.id) + ' ' +
176 WEBrick::HTTPUtils.escape(
177 m.from.to_s.split('/', 2)[1] ),
178 'receiptRequested' => 'all',
179 'callbackUrl' => ARGV[6]
180 })
181 response = http.request(request)
182
183 puts 'API response to send: ' + response.to_s + ' with code ' +
184 response.code + ', body "' + response.body + '"'
185
186 if response.code != '201'
187 # TODO: add text re unexpected code; mention code number
188 write_to_stream error_msg(m.reply, m.body, :cancel,
189 'internal-server-error')
190 next
191 end
192 end
193
194 def self.user_cap_identities()
195 [{:category => 'client', :type => 'sms'}]
196 end
197
198 def self.user_cap_features()
199 [
200 "urn:xmpp:receipts",
201 "urn:xmpp:jingle:1", "urn:xmpp:jingle:transports:ibb:1",
202
203 # TODO: eventually add more efficient file transfer mechanisms
204 #"urn:xmpp:jingle:transports:s5b:1",
205
206 # TODO: MUST add all relevant/reasonable vers of file-transfer
207 #"urn:xmpp:jingle:apps:file-transfer:4"
208 "urn:xmpp:jingle:apps:file-transfer:3"
209 ]
210 end
211
212 presence :subscribe? do |p|
213 puts "PRESENCE1: #{p.inspect}"
214
215
216 # subscriptions are allowed from anyone - send reply immediately
217 msg = Blather::Stanza::Presence.new
218 msg.to = p.from
219 msg.from = p.to
220 msg.type = :subscribed
221
222 puts "RESPONSE5: #{msg.inspect}"
223 write_to_stream msg
224
225
226 # send a <presence> immediately; not automatically probed for it
227 # TODO: refactor so no "presence :probe? do |p|" duplicate below
228 caps = Blather::Stanza::Capabilities.new
229 # TODO: user a better node URI (?)
230 caps.node = 'http://catapult.sgx.soprani.ca/'
231 caps.identities = user_cap_identities()
232 caps.features = user_cap_features()
233
234 msg = caps.c
235 msg.to = p.from
236 msg.from = p.to.to_s + '/sgx'
237
238 puts 'RESPONSE0: ' + msg.inspect
239 write_to_stream msg
240 end
241
242 presence :probe? do |p|
243 puts 'PRESENCE2: ' + p.inspect
244
245 caps = Blather::Stanza::Capabilities.new
246 # TODO: user a better node URI (?)
247 caps.node = 'http://catapult.sgx.soprani.ca/'
248 caps.identities = user_cap_identities()
249 caps.features = user_cap_features()
250
251 msg = caps.c
252 msg.to = p.from
253 msg.from = p.to.to_s + '/sgx'
254
255 puts 'RESPONSE6: ' + msg.inspect
256 write_to_stream msg
257 end
258
259 iq '/iq/ns:jingle', :ns => 'urn:xmpp:jingle:1' do |i, jn|
260 puts "IQj: #{i.inspect}"
261
262 if jn[0]['action'] == 'transport-accept'
263 puts "REPLY0: #{i.reply.inspect}"
264 write_to_stream i.reply
265 next
266 elsif jn[0]['action'] == 'session-terminate'
267 # TODO: unexpected (usually we do this; handle?)
268 puts "TERMINATED"
269 next
270 elsif jn[0]['action'] == 'transport-info'
271 # TODO: unexpected, but should handle in a nice way
272 puts "FAIL!!!"
273 next
274 elsif i.type == :error
275 # TODO: do something, maybe terminating the connection
276 puts 'ERROR!!!'
277 next
278 end
279
280 # TODO: should probably confirm we got session-initiate here
281
282 write_to_stream i.reply
283 puts "RESPONSE8: #{i.reply.inspect}"
284
285 msg = Blather::Stanza::Iq.new :set
286 msg.to = i.from
287 msg.from = i.to
288
289 cn = jn.children.find { |v| v.element_name == "content" }
290 puts 'CN-name: ' + cn['name']
291 puts 'JN-sid: ' + jn[0]['sid']
292
293 ibb_found = false
294 last_sid = ''
295 for child in cn.children
296 if child.element_name == 'transport'
297 puts 'TPORT: ' + child.namespace.href
298 last_sid = child['sid']
299 if 'urn:xmpp:jingle:transports:ibb:1' ==
300 child.namespace.href
301
302 ibb_found = true
303 break
304 end
305 end
306 end
307
308 j = Nokogiri::XML::Node.new 'jingle',msg.document
309 j['xmlns'] = 'urn:xmpp:jingle:1'
310 j['sid'] = jn[0]['sid']
311 msg.add_child(j)
312
313 content = Nokogiri::XML::Node.new 'content',msg.document
314 content['name'] = cn['name']
315 content['creator'] = 'initiator'
316 j.add_child(content)
317
318 transport = Nokogiri::XML::Node.new 'transport',msg.document
319 # TODO: make block-size more variable and/or dependent on sender
320 transport['block-size'] = '4096'
321 transport['xmlns'] = 'urn:xmpp:jingle:transports:ibb:1'
322 if ibb_found
323 transport['sid'] = last_sid
324 j['action'] = 'session-accept'
325 j['responder'] = i.from
326
327 dsc = Nokogiri::XML::Node.new 'description',msg.document
328 dsc['xmlns'] = 'urn:xmpp:jingle:apps:file-transfer:3'
329 content.add_child(dsc)
330 else
331 # for Conversations - it tries s5b even if caps ibb-only
332 transport['sid'] = @uuid_gen.generate
333 j['action'] = 'transport-replace'
334 j['initiator'] = i.from
335 end
336 content.add_child(transport)
337
338 @jingle_sids[transport['sid']] = jn[0]['sid']
339
340 # TODO: save <date> as well? Gajim sends, Conversations does not
341 # TODO: save/validate <size> with eventual full received length
342 fname = cn.children.find { |v| v.element_name == "description"
343 }.children.find { |w| w.element_name == "offer"
344 }.children.find { |x| x.element_name == "file"
345 }.children.find { |y| y.element_name == "name" }
346 @jingle_fnames[transport['sid']] = fname.text
347
348 puts "RESPONSE9: #{msg.inspect}"
349 write_to_stream msg
350 end
351
352 iq '/iq/ns:open', :ns =>
353 'http://jabber.org/protocol/ibb' do |i, on|
354
355 puts "IQo: #{i.inspect}"
356
357 @partial_data[on[0]['sid']] = ''
358 write_to_stream i.reply
359 end
360
361 iq '/iq/ns:data', :ns =>
362 'http://jabber.org/protocol/ibb' do |i, dn|
363
364 @partial_data[dn[0]['sid']] += Base64.decode64(dn[0].text)
365 write_to_stream i.reply
366 end
367
368 iq '/iq/ns:close', :ns =>
369 'http://jabber.org/protocol/ibb' do |i, cn|
370
371 puts "IQc: #{i.inspect}"
372 write_to_stream i.reply
373
374 # TODO: refactor below so that "message :chat?" uses same code
375 num_dest = i.to.to_s.split('@', 2)[0]
376
377 if num_dest[0] != '+'
378 # check to see if a valid shortcode context is specified
379 num_and_context = num_dest.split(';', 2)
380 if num_and_context[1] and num_and_context[1] ==
381 'phone-context=ca-us.phone-context.soprani.ca'
382
383 # TODO: check if num_dest is fully numeric
384 num_dest = num_and_context[0]
385 else
386 # TODO: text re num not (yet) supportd/implmentd
387 write_to_stream error_msg(i.reply, nil,
388 :cancel, 'item-not-found')
389 next
390 end
391 end
392
393 bare_jid = i.from.to_s.split('/', 2)[0]
394 cred_key = "catapult_cred-" + bare_jid
395
396 # TODO: connect at start of program instead
397 conn = Hiredis::Connection.new
398 conn.connect(ARGV[4], ARGV[5].to_i)
399
400 conn.write ["EXISTS", cred_key]
401 if conn.read == 0
402 conn.disconnect
403
404 # TODO: add text re credentials not being registered
405 write_to_stream error_msg(i.reply, nil, :auth,
406 'registration-required')
407 next
408 end
409
410 conn.write ["LRANGE", cred_key, 0, 3]
411 user_id, api_token, api_secret, users_num = conn.read
412 conn.disconnect
413
414 # upload cached data to server (before success reply)
415 media_name = Time.now.utc.iso8601 + '_' + @uuid_gen.generate +
416 '_' + @jingle_fnames[cn[0]['sid']]
417 puts 'name to save: ' + media_name
418
419 uri = URI.parse('https://api.catapult.inetwork.com')
420 http = Net::HTTP.new(uri.host, uri.port)
421 http.use_ssl = true
422 request = Net::HTTP::Put.new('/v1/users/' + user_id +
423 '/media/' + media_name)
424 request.basic_auth api_token, api_secret
425 request.body = @partial_data[cn[0]['sid']]
426 response = http.request(request)
427
428 puts 'eAPI response to send: ' + response.to_s + ' with code ' +
429 response.code + ', body "' + response.body + '"'
430
431 if response.code != '200'
432 # TODO: add text re unexpected code; mention code number
433 write_to_stream error_msg(i.reply, nil, :cancel,
434 'internal-server-error')
435 next
436 end
437
438 uri = URI.parse('https://api.catapult.inetwork.com')
439 http = Net::HTTP.new(uri.host, uri.port)
440 http.use_ssl = true
441 request = Net::HTTP::Post.new('/v1/users/' + user_id +
442 '/messages')
443 request.basic_auth api_token, api_secret
444 request.add_field('Content-Type', 'application/json')
445 request.body = JSON.dump({
446 'from' => users_num,
447 'to' => num_dest,
448 'text' => '',
449 'media' => [
450 'https://api.catapult.inetwork.com/v1/users/' +
451 user_id + '/media/' + media_name],
452 'tag' =>
453 # callbacks need both the id and resourcepart
454 WEBrick::HTTPUtils.escape(i.id) + ' ' +
455 WEBrick::HTTPUtils.escape(
456 i.from.to_s.split('/', 2)[1] )
457 # TODO: add back when Bandwidth AP supports it (?); now:
458 # "The ''messages'' resource property
459 # ''receiptRequested'' is not supported for MMS"
460 #'receiptRequested' => 'all',
461 #'callbackUrl' => ARGV[6]
462 })
463 response = http.request(request)
464
465 puts 'mAPI response to send: ' + response.to_s + ' with code ' +
466 response.code + ', body "' + response.body + '"'
467
468 if response.code != '201'
469 # TODO: add text re unexpected code; mention code number
470 write_to_stream error_msg(i.reply, nil, :cancel,
471 'internal-server-error')
472 next
473 end
474
475 @partial_data[cn[0]['sid']] = ''
476
477 # received the complete file so now close the stream
478 msg = Blather::Stanza::Iq.new :set
479 msg.to = i.from
480 msg.from = i.to
481
482 j = Nokogiri::XML::Node.new 'jingle',msg.document
483 j['xmlns'] = 'urn:xmpp:jingle:1'
484 j['action'] = 'session-terminate'
485 j['sid'] = @jingle_sids[cn[0]['sid']]
486 msg.add_child(j)
487
488 r = Nokogiri::XML::Node.new 'reason',msg.document
489 s = Nokogiri::XML::Node.new 'success',msg.document
490 r.add_child(s)
491 j.add_child(r)
492
493 puts 'RESPONSE1: ' + msg.inspect
494 write_to_stream msg
495 end
496
497 iq '/iq/ns:query', :ns =>
498 'http://jabber.org/protocol/disco#items' do |i, xpath_result|
499
500 write_to_stream i.reply
501 end
502
503 iq '/iq/ns:query', :ns =>
504 'http://jabber.org/protocol/disco#info' do |i, xpath_result|
505
506 # respond to capabilities request for an sgx-catapult number JID
507 if i.to.node
508 # TODO: confirm the node URL is expected using below
509 #puts "XR[node]: #{xpath_result[0]['node']}"
510
511 msg = i.reply
512 msg.identities = user_cap_identities()
513 msg.features = user_cap_features()
514
515 puts 'RESPONSE7: ' + msg.inspect
516 write_to_stream msg
517 next
518 end
519
520 # respond to capabilities request for sgx-catapult itself
521 msg = i.reply
522 msg.identities = [{:name =>
523 'Soprani.ca Gateway to XMPP - Catapult',
524 :type => 'sms-ctplt', :category => 'gateway'}]
525 msg.features = ["jabber:iq:register",
526 "jabber:iq:gateway", "jabber:iq:private",
527 "http://jabber.org/protocol/disco#info",
528 "http://jabber.org/protocol/commands",
529 "http://jabber.org/protocol/muc"]
530 write_to_stream msg
531 end
532
533 iq '/iq/ns:query', :ns => 'jabber:iq:register' do |i, qn|
534 puts "IQ: #{i.inspect}"
535
536 if i.type == :set
537 xn = qn.children.find { |v| v.element_name == "x" }
538
539 user_id = ''
540 api_token = ''
541 api_secret = ''
542 phone_num = ''
543
544 if xn.nil?
545 user_id = qn.children.find {
546 |v| v.element_name == "nick" }
547 api_token = qn.children.find {
548 |v| v.element_name == "username" }
549 api_secret = qn.children.find {
550 |v| v.element_name == "password" }
551 phone_num = qn.children.find {
552 |v| v.element_name == "phone" }
553 else
554 for field in xn.children
555 if field.element_name == "field"
556 val = field.children.find { |v|
557 v.element_name == "value" }
558
559 case field['var']
560 when 'nick'
561 user_id = val.text
562 when 'username'
563 api_token = val.text
564 when 'password'
565 api_secret = val.text
566 when 'phone'
567 phone_num = val.text
568 else
569 # TODO: error
570 puts "?: " +field['var']
571 end
572 end
573 end
574 end
575
576 if phone_num[0] != '+'
577 # TODO: add text re number not (yet) supported
578 write_to_stream error_msg(i.reply, qn, :cancel,
579 'item-not-found')
580 next
581 end
582
583 uri = URI.parse('https://api.catapult.inetwork.com')
584 http = Net::HTTP.new(uri.host, uri.port)
585 http.use_ssl = true
586 request = Net::HTTP::Get.new('/v1/users/' + user_id +
587 '/phoneNumbers/' + phone_num)
588 request.basic_auth api_token, api_secret
589 response = http.request(request)
590
591 puts 'API response: ' + response.to_s + ' with code ' +
592 response.code + ', body "' + response.body + '"'
593
594 if response.code == '200'
595 params = JSON.parse response.body
596 if params['numberState'] == 'enabled'
597 jid_key = "catapult_jid-" + phone_num
598
599 bare_jid = i.from.to_s.split('/', 2)[0]
600 cred_key = "catapult_cred-" + bare_jid
601
602 # TODO: pre-validate ARGV[5] is integer
603 conn = Hiredis::Connection.new
604 conn.connect(ARGV[4], ARGV[5].to_i)
605
606 # TODO: use SETNX instead
607 conn.write ["EXISTS", jid_key]
608 if conn.read == 1
609 conn.disconnect
610
611 # TODO: add txt re num exists
612 write_to_stream error_msg(
613 i.reply, qn, :cancel,
614 'conflict')
615 next
616 end
617
618 conn.write ["EXISTS", cred_key]
619 if conn.read == 1
620 conn.disconnect
621
622 # TODO: add txt re already exist
623 write_to_stream error_msg(
624 i.reply, qn, :cancel,
625 'conflict')
626 next
627 end
628
629 conn.write ["SET", jid_key, bare_jid]
630 if conn.read != 1
631 conn.disconnect
632
633 # TODO: catch/relay RuntimeError
634 # TODO: add txt re push failure
635 write_to_stream error_msg(
636 i.reply, qn, :cancel,
637 'internal-server-error')
638 next
639 end
640
641 conn.write ["RPUSH",cred_key,user_id]
642 conn.write ["RPUSH",cred_key,api_token]
643 conn.write ["RPUSH",cred_key,api_secret]
644 conn.write ["RPUSH",cred_key,phone_num]
645
646 # TODO: confirm cred_key list size == 4
647
648 for n in 1..4 do
649 # TODO: catch/relay RuntimeError
650 result = conn.read
651 if result != n
652 conn.disconnect
653
654 write_to_stream(
655 error_msg(
656 i.reply, qn, :cancel,
657 'internal-server-error')
658 )
659 next
660 end
661 end
662 conn.disconnect
663
664 write_to_stream i.reply
665 else
666 # TODO: add text re number disabled
667 write_to_stream error_msg(i.reply, qn,
668 :modify, 'not-acceptable')
669 end
670 elsif response.code == '401'
671 # TODO: add text re bad credentials
672 write_to_stream error_msg(i.reply, qn, :auth,
673 'not-authorized')
674 elsif response.code == '404'
675 # TODO: add text re number not found or disabled
676 write_to_stream error_msg(i.reply, qn, :cancel,
677 'item-not-found')
678 else
679 # TODO: add text re misc error, and mention code
680 write_to_stream error_msg(i.reply, qn, :modify,
681 'not-acceptable')
682 end
683
684 elsif i.type == :get
685 orig = i.reply
686
687 msg = Nokogiri::XML::Node.new 'query',orig.document
688 msg['xmlns'] = 'jabber:iq:register'
689 n1 = Nokogiri::XML::Node.new 'instructions',msg.document
690 n1.content= "Enter the information from your Account " +
691 "page as well as the Phone Number\nin your " +
692 "account you want to use (ie. '+12345678901')" +
693 ".\nUser Id is nick, API Token is username, " +
694 "API Secret is password, Phone Number is phone"+
695 ".\n\nThe source code for this gateway is at " +
696 "https://github.com/ossguy/sgx-catapult ." +
697 "\nCopyright (C) 2017 Denver Gingerich and " +
698 "others, licensed under AGPLv3+."
699 n2 = Nokogiri::XML::Node.new 'nick',msg.document
700 n3 = Nokogiri::XML::Node.new 'username',msg.document
701 n4 = Nokogiri::XML::Node.new 'password',msg.document
702 n5 = Nokogiri::XML::Node.new 'phone',msg.document
703 msg.add_child(n1)
704 msg.add_child(n2)
705 msg.add_child(n3)
706 msg.add_child(n4)
707 msg.add_child(n5)
708
709 x = Blather::Stanza::X.new :form, [
710 {:required => true, :type => :"text-single",
711 :label => 'User Id', :var => 'nick'},
712 {:required => true, :type => :"text-single",
713 :label => 'API Token', :var => 'username'},
714 {:required => true, :type => :"text-private",
715 :label => 'API Secret', :var => 'password'},
716 {:required => true, :type => :"text-single",
717 :label => 'Phone Number', :var => 'phone'}
718 ]
719 x.title= 'Register for ' +
720 'Soprani.ca Gateway to XMPP - Catapult'
721 x.instructions= "Enter the details from your Account " +
722 "page as well as the Phone Number\nin your " +
723 "account you want to use (ie. '+12345678901')" +
724 ".\n\nThe source code for this gateway is at " +
725 "https://github.com/ossguy/sgx-catapult ." +
726 "\nCopyright (C) 2017 Denver Gingerich and " +
727 "others, licensed under AGPLv3+."
728 msg.add_child(x)
729
730 orig.add_child(msg)
731 puts "RESPONSE2: #{orig.inspect}"
732 write_to_stream orig
733 puts "SENT"
734 end
735 end
736
737 subscription(:request?) do |s|
738 # TODO: are these the best to return? really need '!' here?
739 #write_to_stream s.approve!
740 #write_to_stream s.request!
741 end
742end
743
744[:INT, :TERM].each do |sig|
745 trap(sig) {
746 puts 'Shutting down gateway...'
747 SGXcatapult.shutdown
748 puts 'Gateway has terminated.'
749
750 EM.stop
751 }
752end
753
754class ReceiptMessage < Blather::Stanza
755 def self.new(to = nil)
756 node = super :message
757 node.to = to
758 node
759 end
760end
761
762class WebhookHandler < Goliath::API
763 def send_media(from, to, media_url)
764 # we assume media_url is of the form (always the case so far):
765 # https://api.catapult.inetwork.com/v1/users/[uid]/media/[file]
766
767 # the caller must guarantee that 'to' is a bare JID
768 proxy_url = ARGV[8] + to + '/' + media_url.split('/', 8)[7]
769
770 puts 'ORIG_URL: ' + media_url
771 puts 'PROX_URL: ' + proxy_url
772
773 # put URL in the body (so Conversations will still see it)...
774 msg = Blather::Stanza::Message.new(to, proxy_url)
775 msg.from = from
776
777 # ...but also provide URL in XEP-0066 (OOB) fashion
778 # TODO: confirm client supports OOB or don't send this
779 x = Nokogiri::XML::Node.new 'x', msg.document
780 x['xmlns'] = 'jabber:x:oob'
781
782 urln = Nokogiri::XML::Node.new 'url', msg.document
783 urlc = Nokogiri::XML::Text.new proxy_url, msg.document
784
785 urln.add_child(urlc)
786 x.add_child(urln)
787 msg.add_child(x)
788
789 SGXcatapult.write(msg)
790 end
791
792 def response(env)
793 puts 'ENV: ' + env.to_s
794 body = Rack::Request.new(env).body.read
795 puts 'BODY: ' + body
796 params = JSON.parse body
797
798 users_num = ''
799 others_num = ''
800 if params['direction'] == 'in'
801 users_num = params['to']
802 others_num = params['from']
803 elsif params['direction'] == 'out'
804 users_num = params['from']
805 others_num = params['to']
806 else
807 # TODO: exception or similar
808 puts "big problem: '" + params['direction'] + "'"
809 return [200, {}, "OK"]
810 end
811
812 jid_key = "catapult_jid-" + users_num
813
814 if others_num[0] != '+'
815 # TODO: check that others_num actually a shortcode first
816 others_num = others_num +
817 ';phone-context=ca-us.phone-context.soprani.ca'
818 end
819
820 conn = Hiredis::Connection.new
821 conn.connect(ARGV[4], ARGV[5].to_i)
822
823 conn.write ["EXISTS", jid_key]
824 if conn.read == 0
825 conn.disconnect
826
827 puts "jid_key (#{jid_key}) DNE; Catapult misconfigured?"
828
829 # TODO: likely not appropriate; give error to Catapult?
830 # TODO: add text re credentials not being registered
831 #write_to_stream error_msg(m.reply, m.body, :auth,
832 # 'registration-required')
833 return [200, {}, "OK"]
834 end
835
836 conn.write ["GET", jid_key]
837 bare_jid = conn.read
838 conn.disconnect
839
840 msg = ''
841 case params['direction']
842 when 'in'
843 text = ''
844 case params['eventType']
845 when 'sms'
846 text = params['text']
847 when 'mms'
848 has_media = false
849 params['media'].each do |media_url|
850 if not media_url.end_with?('.smil',
851 '.txt', '.xml')
852
853 has_media = true
854 send_media(others_num + '@' +
855 ARGV[0],
856 bare_jid, media_url)
857 end
858 end
859
860 if params['text'].empty?
861 if not has_media
862 text = '[suspected group msg ' +
863 'with no text (odd)]'
864 end
865 else
866 if has_media
867 # TODO: write/use a caption XEP
868 text = params['text']
869 else
870 text = '[suspected group msg ' +
871 '(recipient list not ' +
872 'available) with ' +
873 'following text] ' +
874 params['text']
875 end
876 end
877
878 # ie. if text param non-empty or had no media
879 if not text.empty?
880 msg = Blather::Stanza::Message.new(
881 bare_jid, text)
882 msg.from = others_num + '@' + ARGV[0]
883 SGXcatapult.write(msg)
884 end
885
886 return [200, {}, "OK"]
887 else
888 text = "unknown type (#{params['eventType']})" +
889 " with text: " + params['text']
890
891 # TODO log/notify of this properly
892 puts text
893 end
894
895 msg = Blather::Stanza::Message.new(bare_jid, text)
896 else # per prior switch, this is: params['direction'] == 'out'
897 tag_parts = params['tag'].split(' ', 2)
898 id = WEBrick::HTTPUtils.unescape(tag_parts[0])
899 resourcepart = WEBrick::HTTPUtils.unescape(tag_parts[1])
900
901 case params['deliveryState']
902 when 'not-delivered'
903 # create a bare message like the one user sent
904 msg = Blather::Stanza::Message.new(
905 others_num + '@' + ARGV[0])
906 msg.from = bare_jid + '/' + resourcepart
907 msg['id'] = id
908
909 # create an error reply to the bare message
910 msg = Blather::StanzaError.new(msg,
911 'recipient-unavailable', :wait).to_node
912 when 'delivered'
913 msg = ReceiptMessage.new(bare_jid)
914
915 # TODO: put in member/instance variable
916 uuid_gen = UUID.new
917 msg['id'] = uuid_gen.generate
918
919 # TODO: send only when requested per XEP-0184
920 rcvd = Nokogiri::XML::Node.new 'received',
921 msg.document
922 rcvd['xmlns'] = 'urn:xmpp:receipts'
923 rcvd['id'] = id
924 msg.add_child(rcvd)
925 when 'waiting'
926 # can't really do anything with it; nice to know
927 puts "message with id #{id} waiting"
928 return [200, {}, "OK"]
929 else
930 # TODO: notify somehow of unknown state receivd?
931 puts "message with id #{id} has " +
932 "other state #{params['deliveryState']}"
933 return [200, {}, "OK"]
934 end
935
936 puts "RESPONSE4: #{msg.inspect}"
937 end
938
939 msg.from = others_num + '@' + ARGV[0]
940 SGXcatapult.write(msg)
941
942 [200, {}, "OK"]
943 end
944end
945
946EM.run do
947 SGXcatapult.run
948
949 # required when using Prosody otherwise disconnects on 6-hour inactivity
950 EM.add_periodic_timer(3600) do
951 msg = Blather::Stanza::Iq::Ping.new(:get, 'localhost')
952 msg.from = ARGV[0]
953 SGXcatapult.write(msg)
954 end
955
956 server = Goliath::Server.new('0.0.0.0', ARGV[7].to_i)
957 server.api = WebhookHandler.new
958 server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
959 server.logger = Log4r::Logger.new('goliath')
960 server.logger.add(Log4r::StdoutOutputter.new('console'))
961 server.logger.level = Log4r::INFO
962 server.start
963end