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 presence :subscribe? 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 subscription(:request?) do |s|
808 # TODO: are these the best to return? really need '!' here?
809 #write_to_stream s.approve!
810 #write_to_stream s.request!
811 end
812
813 iq :get? do |i|
814 write_to_stream error_msg(i.reply, i.children, 'cancel', 'feature-not-implemented')
815 end
816
817 iq :set? do |i|
818 write_to_stream error_msg(i.reply, i.children, 'cancel', 'feature-not-implemented')
819 end
820end
821
822class ReceiptMessage < Blather::Stanza
823 def self.new(to=nil)
824 node = super :message
825 node.to = to
826 node
827 end
828end
829
830class WebhookHandler < Goliath::API
831 use Goliath::Rack::Params
832
833 def response(env)
834 puts 'ENV: ' + env.reject{ |k| k == 'params' }.to_s
835
836 users_num = ''
837 others_num = ''
838 if params['direction'] == 'in'
839 users_num = params['to']
840 others_num = params['from']
841 elsif params['direction'] == 'out'
842 users_num = params['from']
843 others_num = params['to']
844 else
845 # TODO: exception or similar
846 puts "big problem: '" + params['direction'] + "'" + body
847 return [200, {}, "OK"]
848 end
849
850 puts 'BODY - messageId: ' + params['messageId'] +
851 ', eventType: ' + params['eventType'] +
852 ', time: ' + params['time'] +
853 ', direction: ' + params['direction'] +
854 ', state: ' + params['state'] +
855 ', deliveryState: ' + (params['deliveryState'] ?
856 params['deliveryState'] : 'NONE') +
857 ', deliveryCode: ' + (params['deliveryCode'] ?
858 params['deliveryCode'] : 'NONE') +
859 ', deliveryDesc: ' + (params['deliveryDescription'] ?
860 params['deliveryDescription'] : 'NONE') +
861 ', tag: ' + (params['tag'] ? params['tag'] : 'NONE') +
862 ', media: ' + (params['media'] ? params['media'].to_s :
863 'NONE')
864
865 if others_num[0] != '+'
866 # TODO: check that others_num actually a shortcode first
867 others_num +=
868 ';phone-context=ca-us.phone-context.soprani.ca'
869 end
870
871 jid_key = "catapult_jid-#{users_num}"
872 bare_jid = REDIS.get(jid_key).promise.sync
873
874 if !bare_jid
875 puts "jid_key (#{jid_key}) DNE; Catapult misconfigured?"
876
877 # TODO: likely not appropriate; give error to Catapult?
878 # TODO: add text re credentials not being registered
879 #write_to_stream error_msg(m.reply, m.body, :auth,
880 # 'registration-required')
881 return [200, {}, "OK"]
882 end
883
884 msg = ''
885 case params['direction']
886 when 'in'
887 text = ''
888 case params['eventType']
889 when 'sms'
890 text = params['text']
891 when 'mms'
892 has_media = false
893 params['media'].each do |media_url|
894 if not media_url.end_with?(
895 '.smil', '.txt', '.xml'
896 )
897
898 has_media = true
899 SGXcatapult.send_media(
900 others_num + '@' +
901 ARGV[0],
902 bare_jid, media_url
903 )
904 end
905 end
906
907 if params['text'].empty?
908 if not has_media
909 text = '[suspected group msg '\
910 'with no text (odd)]'
911 end
912 else
913 text = if has_media
914 # TODO: write/use a caption XEP
915 params['text']
916 else
917 '[suspected group msg '\
918 '(recipient list not '\
919 'available) with '\
920 'following text] ' +
921 params['text']
922 end
923 end
924
925 # ie. if text param non-empty or had no media
926 if not text.empty?
927 msg = Blather::Stanza::Message.new(
928 bare_jid, text)
929 msg.from = others_num + '@' + ARGV[0]
930 SGXcatapult.write(msg)
931 end
932
933 return [200, {}, "OK"]
934 else
935 text = "unknown type (#{params['eventType']})"\
936 " with text: " + params['text']
937
938 # TODO: log/notify of this properly
939 puts text
940 end
941
942 msg = Blather::Stanza::Message.new(bare_jid, text)
943 else # per prior switch, this is: params['direction'] == 'out'
944 tag_parts = params['tag'].split(/ /, 2)
945 id = WEBrick::HTTPUtils.unescape(tag_parts[0])
946 resourcepart = WEBrick::HTTPUtils.unescape(tag_parts[1])
947
948 case params['deliveryState']
949 when 'not-delivered'
950 # create a bare message like the one user sent
951 msg = Blather::Stanza::Message.new(
952 others_num + '@' + ARGV[0])
953 msg.from = bare_jid + '/' + resourcepart
954 msg['id'] = id
955
956 # create an error reply to the bare message
957 msg = Blather::StanzaError.new(
958 msg,
959 'recipient-unavailable',
960 :wait
961 ).to_node
962 when 'delivered'
963 msg = ReceiptMessage.new(bare_jid)
964
965 # TODO: put in member/instance variable
966 msg['id'] = SecureRandom.uuid
967
968 # TODO: send only when requested per XEP-0184
969 rcvd = Nokogiri::XML::Node.new(
970 'received',
971 msg.document
972 )
973 rcvd['xmlns'] = 'urn:xmpp:receipts'
974 rcvd['id'] = id
975 msg.add_child(rcvd)
976 when 'waiting'
977 # can't really do anything with it; nice to know
978 puts "message with id #{id} waiting"
979 return [200, {}, "OK"]
980 else
981 # TODO: notify somehow of unknown state receivd?
982 puts "message with id #{id} has "\
983 "other state #{params['deliveryState']}"
984 return [200, {}, "OK"]
985 end
986
987 puts "RESPONSE4: #{msg.inspect}"
988 end
989
990 msg.from = others_num + '@' + ARGV[0]
991 SGXcatapult.write(msg)
992
993 [200, {}, "OK"]
994
995 rescue Exception => e
996 puts 'Shutting down gateway due to exception 013: ' + e.message
997 SGXcatapult.shutdown
998 puts 'Gateway has terminated.'
999 EM.stop
1000 end
1001end
1002
1003at_exit do
1004 $stdout.sync = true
1005
1006 puts "Soprani.ca/SMS Gateway for XMPP - Catapult\n"\
1007 "==>> last commit of this version is " + `git rev-parse HEAD` + "\n"
1008
1009 if ARGV.size != 7
1010 puts "Usage: sgx-catapult.rb <component_jid> "\
1011 "<component_password> <server_hostname> "\
1012 "<server_port> <delivery_receipt_url> "\
1013 "<http_listen_port> <mms_proxy_prefix_url>"
1014 exit 0
1015 end
1016
1017 t = Time.now
1018 puts "LOG %d.%09d: starting...\n\n" % [t.to_i, t.nsec]
1019
1020 EM.run do
1021 REDIS = EM::Hiredis.connect
1022
1023 SGXcatapult.run
1024
1025 # required when using Prosody otherwise disconnects on 6-hour inactivity
1026 EM.add_periodic_timer(3600) do
1027 msg = Blather::Stanza::Iq::Ping.new(:get, 'localhost')
1028 msg.from = ARGV[0]
1029 SGXcatapult.write(msg)
1030 end
1031
1032 server = Goliath::Server.new('0.0.0.0', ARGV[5].to_i)
1033 server.api = WebhookHandler.new
1034 server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
1035 server.logger = Log4r::Logger.new('goliath')
1036 server.logger.add(Log4r::StdoutOutputter.new('console'))
1037 server.logger.level = Log4r::INFO
1038 server.start do
1039 ["INT", "TERM"].each do |sig|
1040 trap(sig) do
1041 puts 'Shutting down gateway...'
1042 SGXcatapult.shutdown
1043
1044 puts 'Gateway has terminated.'
1045 EM.stop
1046 end
1047 end
1048 end
1049 end
1050end