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