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