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