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