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