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