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