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