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