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