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