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