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 def self.check_then_register(user_id, api_token, api_secret, phone_num,
564 i, qn)
565
566 jid_key = "catapult_jid-" + phone_num
567
568 bare_jid = i.from.to_s.split('/', 2)[0]
569 cred_key = "catapult_cred-" + bare_jid
570
571 # TODO: pre-validate ARGV[5] is integer
572 conn = Hiredis::Connection.new
573 conn.connect(ARGV[4], ARGV[5].to_i)
574
575 # TODO: use SETNX instead
576 conn.write ["EXISTS", jid_key]
577 if conn.read == 1
578 conn.disconnect
579
580 # TODO: add txt re num exists
581 write_to_stream error_msg(
582 i.reply, qn, :cancel,
583 'conflict')
584 return false
585 end
586
587 conn.write ["EXISTS", cred_key]
588 if conn.read == 1
589 conn.disconnect
590
591 # TODO: add txt re already exist
592 write_to_stream error_msg(
593 i.reply, qn, :cancel,
594 'conflict')
595 return false
596 end
597
598 conn.write ["SET", jid_key, bare_jid]
599 if conn.read != 'OK'
600 conn.disconnect
601
602 # TODO: catch/relay RuntimeError
603 # TODO: add txt re push failure
604 write_to_stream error_msg(
605 i.reply, qn, :cancel,
606 'internal-server-error')
607 return false
608 end
609
610 conn.write ["RPUSH", cred_key, user_id]
611 conn.write ["RPUSH", cred_key, api_token]
612 conn.write ["RPUSH", cred_key, api_secret]
613 conn.write ["RPUSH", cred_key, phone_num]
614
615 # TODO: confirm cred_key list size == 4
616
617 (1..4).each do |n|
618 # TODO: catch/relay RuntimeError
619 result = conn.read
620 if result != n
621 conn.disconnect
622
623 write_to_stream error_msg(
624 i.reply, qn, :cancel,
625 'internal-server-error')
626 return false
627 end
628 end
629 conn.disconnect
630
631 write_to_stream i.reply
632
633 return true
634 end
635
636 iq '/iq/ns:query', ns: 'jabber:iq:register' do |i, qn|
637 puts "IQ: #{i.inspect}"
638
639 if i.type == :set
640 xn = qn.children.find { |v| v.element_name == "x" }
641
642 user_id = ''
643 api_token = ''
644 api_secret = ''
645 phone_num = ''
646
647 if xn.nil?
648 user_id = qn.children.find { |v|
649 v.element_name == "nick"
650 }
651 api_token = qn.children.find { |v|
652 v.element_name == "username"
653 }
654 api_secret = qn.children.find { |v|
655 v.element_name == "password"
656 }
657 phone_num = qn.children.find { |v|
658 v.element_name == "phone"
659 }
660 else
661 xn.children.each do |field|
662 if field.element_name == "field"
663 val = field.children.find { |v|
664 v.element_name == "value"
665 }
666
667 case field['var']
668 when 'nick'
669 user_id = val.text
670 when 'username'
671 api_token = val.text
672 when 'password'
673 api_secret = val.text
674 when 'phone'
675 phone_num = val.text
676 else
677 # TODO: error
678 puts "?: " +field['var']
679 end
680 end
681 end
682 end
683
684 if phone_num[0] != '+'
685 # TODO: add text re number not (yet) supported
686 write_to_stream error_msg(
687 i.reply, qn, :cancel,
688 'item-not-found'
689 )
690 next
691 end
692
693 uri = URI.parse('https://api.catapult.inetwork.com')
694 http = Net::HTTP.new(uri.host, uri.port)
695 http.use_ssl = true
696 request = Net::HTTP::Get.new('/v1/users/' + user_id +
697 '/phoneNumbers/' + phone_num)
698 request.basic_auth api_token, api_secret
699 response = http.request(request)
700
701 puts 'API response: ' + response.to_s + ' with code ' +
702 response.code + ', body "' + response.body + '"'
703
704 if response.code == '200'
705 params = JSON.parse response.body
706 if params['numberState'] == 'enabled'
707 if not check_then_register(
708 user_id, api_token, api_secret,
709 phone_num, i, qn
710 )
711 next
712 end
713 else
714 # TODO: add text re number disabled
715 write_to_stream error_msg(
716 i.reply, qn,
717 :modify, 'not-acceptable'
718 )
719 end
720 elsif response.code == '401'
721 # TODO: add text re bad credentials
722 write_to_stream error_msg(
723 i.reply, qn, :auth,
724 'not-authorized'
725 )
726 elsif response.code == '404'
727 # TODO: add text re number not found or disabled
728 write_to_stream error_msg(
729 i.reply, qn, :cancel,
730 'item-not-found'
731 )
732 else
733 # TODO: add text re misc error, and mention code
734 write_to_stream error_msg(
735 i.reply, qn, :modify,
736 'not-acceptable'
737 )
738 end
739
740 elsif i.type == :get
741 orig = i.reply
742
743 bare_jid = i.from.to_s.split('/', 2)[0]
744 cred_key = "catapult_cred-" + bare_jid
745
746 conn = Hiredis::Connection.new
747 conn.connect(ARGV[4], ARGV[5].to_i)
748 conn.write(["LINDEX", cred_key, 3])
749 existing_number = conn.read
750 conn.disconnect
751
752 msg = Nokogiri::XML::Node.new 'query', orig.document
753 msg['xmlns'] = 'jabber:iq:register'
754
755 if existing_number
756 msg.add_child(
757 Nokogiri::XML::Node.new('registered', msg.document)
758 )
759 end
760
761 n1 = Nokogiri::XML::Node.new 'instructions', msg.document
762 n1.content= "Enter the information from your Account "\
763 "page as well as the Phone Number\nin your "\
764 "account you want to use (ie. '+12345678901')"\
765 ".\nUser Id is nick, API Token is username, "\
766 "API Secret is password, Phone Number is phone"\
767 ".\n\nThe source code for this gateway is at "\
768 "https://gitlab.com/ossguy/sgx-catapult ."\
769 "\nCopyright (C) 2017 Denver Gingerich and "\
770 "others, licensed under AGPLv3+."
771 n2 = Nokogiri::XML::Node.new 'nick', msg.document
772 n3 = Nokogiri::XML::Node.new 'username', msg.document
773 n4 = Nokogiri::XML::Node.new 'password', msg.document
774 n5 = Nokogiri::XML::Node.new 'phone', msg.document
775 n5.content = existing_number.to_s
776 msg.add_child(n1)
777 msg.add_child(n2)
778 msg.add_child(n3)
779 msg.add_child(n4)
780 msg.add_child(n5)
781
782 x = Blather::Stanza::X.new :form, [
783 {
784 required: true, type: :"text-single",
785 label: 'User Id', var: 'nick'
786 },
787 {
788 required: true, type: :"text-single",
789 label: 'API Token', var: 'username'
790 },
791 {
792 required: true, type: :"text-private",
793 label: 'API Secret', var: 'password'
794 },
795 {
796 required: true, type: :"text-single",
797 label: 'Phone Number', var: 'phone',
798 value: existing_number.to_s
799 }
800 ]
801 x.title= 'Register for '\
802 'Soprani.ca Gateway to XMPP - Catapult'
803 x.instructions= "Enter the details from your Account "\
804 "page as well as the Phone Number\nin your "\
805 "account you want to use (ie. '+12345678901')"\
806 ".\n\nThe source code for this gateway is at "\
807 "https://gitlab.com/ossguy/sgx-catapult ."\
808 "\nCopyright (C) 2017 Denver Gingerich and "\
809 "others, licensed under AGPLv3+."
810 msg.add_child(x)
811
812 orig.add_child(msg)
813 puts "RESPONSE2: #{orig.inspect}"
814 write_to_stream orig
815 puts "SENT"
816 end
817 end
818
819 subscription(:request?) do |s|
820 # TODO: are these the best to return? really need '!' here?
821 #write_to_stream s.approve!
822 #write_to_stream s.request!
823 end
824end
825
826[:INT, :TERM].each do |sig|
827 trap(sig) {
828 puts 'Shutting down gateway...'
829 SGXcatapult.shutdown
830 puts 'Gateway has terminated.'
831
832 EM.stop
833 }
834end
835
836class ReceiptMessage < Blather::Stanza
837 def self.new(to = nil)
838 node = super :message
839 node.to = to
840 node
841 end
842end
843
844class WebhookHandler < Goliath::API
845 def send_media(from, to, media_url)
846 # we assume media_url is of the form (always the case so far):
847 # https://api.catapult.inetwork.com/v1/users/[uid]/media/[file]
848
849 # the caller must guarantee that 'to' is a bare JID
850 proxy_url = ARGV[8] + to + '/' + media_url.split('/', 8)[7]
851
852 puts 'ORIG_URL: ' + media_url
853 puts 'PROX_URL: ' + proxy_url
854
855 # put URL in the body (so Conversations will still see it)...
856 msg = Blather::Stanza::Message.new(to, proxy_url)
857 msg.from = from
858
859 # ...but also provide URL in XEP-0066 (OOB) fashion
860 # TODO: confirm client supports OOB or don't send this
861 x = Nokogiri::XML::Node.new 'x', msg.document
862 x['xmlns'] = 'jabber:x:oob'
863
864 urln = Nokogiri::XML::Node.new 'url', msg.document
865 urlc = Nokogiri::XML::Text.new proxy_url, msg.document
866
867 urln.add_child(urlc)
868 x.add_child(urln)
869 msg.add_child(x)
870
871 SGXcatapult.write(msg)
872 end
873
874 def response(env)
875 puts 'ENV: ' + env.to_s
876 body = Rack::Request.new(env).body.read
877 puts 'BODY: ' + body
878 params = JSON.parse body
879
880 users_num = ''
881 others_num = ''
882 if params['direction'] == 'in'
883 users_num = params['to']
884 others_num = params['from']
885 elsif params['direction'] == 'out'
886 users_num = params['from']
887 others_num = params['to']
888 else
889 # TODO: exception or similar
890 puts "big problem: '" + params['direction'] + "'"
891 return [200, {}, "OK"]
892 end
893
894 jid_key = "catapult_jid-" + users_num
895
896 if others_num[0] != '+'
897 # TODO: check that others_num actually a shortcode first
898 others_num +=
899 ';phone-context=ca-us.phone-context.soprani.ca'
900 end
901
902 conn = Hiredis::Connection.new
903 conn.connect(ARGV[4], ARGV[5].to_i)
904
905 conn.write ["EXISTS", jid_key]
906 if conn.read == 0
907 conn.disconnect
908
909 puts "jid_key (#{jid_key}) DNE; Catapult misconfigured?"
910
911 # TODO: likely not appropriate; give error to Catapult?
912 # TODO: add text re credentials not being registered
913 #write_to_stream error_msg(m.reply, m.body, :auth,
914 # 'registration-required')
915 return [200, {}, "OK"]
916 end
917
918 conn.write ["GET", jid_key]
919 bare_jid = conn.read
920 conn.disconnect
921
922 msg = ''
923 case params['direction']
924 when 'in'
925 text = ''
926 case params['eventType']
927 when 'sms'
928 text = params['text']
929 when 'mms'
930 has_media = false
931 params['media'].each do |media_url|
932 if not media_url.end_with?(
933 '.smil', '.txt', '.xml'
934 )
935
936 has_media = true
937 send_media(
938 others_num + '@' +
939 ARGV[0],
940 bare_jid, media_url
941 )
942 end
943 end
944
945 if params['text'].empty?
946 if not has_media
947 text = '[suspected group msg '\
948 'with no text (odd)]'
949 end
950 else
951 text = if has_media
952 # TODO: write/use a caption XEP
953 params['text']
954 else
955 '[suspected group msg '\
956 '(recipient list not '\
957 'available) with '\
958 'following text] ' +
959 params['text']
960 end
961 end
962
963 # ie. if text param non-empty or had no media
964 if not text.empty?
965 msg = Blather::Stanza::Message.new(
966 bare_jid, text)
967 msg.from = others_num + '@' + ARGV[0]
968 SGXcatapult.write(msg)
969 end
970
971 return [200, {}, "OK"]
972 else
973 text = "unknown type (#{params['eventType']})"\
974 " with text: " + params['text']
975
976 # TODO: log/notify of this properly
977 puts text
978 end
979
980 msg = Blather::Stanza::Message.new(bare_jid, text)
981 else # per prior switch, this is: params['direction'] == 'out'
982 tag_parts = params['tag'].split(' ', 2)
983 id = WEBrick::HTTPUtils.unescape(tag_parts[0])
984 resourcepart = WEBrick::HTTPUtils.unescape(tag_parts[1])
985
986 case params['deliveryState']
987 when 'not-delivered'
988 # create a bare message like the one user sent
989 msg = Blather::Stanza::Message.new(
990 others_num + '@' + ARGV[0])
991 msg.from = bare_jid + '/' + resourcepart
992 msg['id'] = id
993
994 # create an error reply to the bare message
995 msg = Blather::StanzaError.new(
996 msg,
997 'recipient-unavailable',
998 :wait
999 ).to_node
1000 when 'delivered'
1001 msg = ReceiptMessage.new(bare_jid)
1002
1003 # TODO: put in member/instance variable
1004 uuid_gen = UUID.new
1005 msg['id'] = uuid_gen.generate
1006
1007 # TODO: send only when requested per XEP-0184
1008 rcvd = Nokogiri::XML::Node.new(
1009 'received',
1010 msg.document
1011 )
1012 rcvd['xmlns'] = 'urn:xmpp:receipts'
1013 rcvd['id'] = id
1014 msg.add_child(rcvd)
1015 when 'waiting'
1016 # can't really do anything with it; nice to know
1017 puts "message with id #{id} waiting"
1018 return [200, {}, "OK"]
1019 else
1020 # TODO: notify somehow of unknown state receivd?
1021 puts "message with id #{id} has "\
1022 "other state #{params['deliveryState']}"
1023 return [200, {}, "OK"]
1024 end
1025
1026 puts "RESPONSE4: #{msg.inspect}"
1027 end
1028
1029 msg.from = others_num + '@' + ARGV[0]
1030 SGXcatapult.write(msg)
1031
1032 [200, {}, "OK"]
1033 end
1034end
1035
1036EM.run do
1037 SGXcatapult.run
1038
1039 # required when using Prosody otherwise disconnects on 6-hour inactivity
1040 EM.add_periodic_timer(3600) do
1041 msg = Blather::Stanza::Iq::Ping.new(:get, 'localhost')
1042 msg.from = ARGV[0]
1043 SGXcatapult.write(msg)
1044 end
1045
1046 server = Goliath::Server.new('0.0.0.0', ARGV[7].to_i)
1047 server.api = WebhookHandler.new
1048 server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
1049 server.logger = Log4r::Logger.new('goliath')
1050 server.logger.add(Log4r::StdoutOutputter.new('console'))
1051 server.logger.level = Log4r::INFO
1052 server.start
1053end