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