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