1#!/usr/bin/env ruby
2# frozen_string_literal: true
3
4# Copyright (C) 2017-2020 Denver Gingerich <denver@ossguy.com>
5# Copyright (C) 2017 Stephen Paul Weber <singpolyma@singpolyma.net>
6#
7# This file is part of sgx-bwmsgsv2.
8#
9# sgx-bwmsgsv2 is free software: you can redistribute it and/or modify it under
10# the terms of the GNU Affero General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# sgx-bwmsgsv2 is distributed in the hope that it will be useful, but WITHOUT
15# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
17# details.
18#
19# You should have received a copy of the GNU Affero General Public License along
20# with sgx-bwmsgsv2. If not, see <http://www.gnu.org/licenses/>.
21
22require 'blather/client/dsl'
23require 'em-hiredis'
24require 'em-http-request'
25require 'json'
26require 'multibases'
27require 'multihashes'
28require 'securerandom'
29require "sentry-ruby"
30require 'time'
31require 'uri'
32require 'webrick'
33
34require 'goliath/api'
35require 'goliath/server'
36require 'log4r'
37
38require 'em_promise'
39require 'em-synchrony'
40
41require_relative 'lib/bandwidth_error'
42require_relative 'lib/bandwidth_tn_options'
43require_relative 'lib/registration_repo'
44
45Sentry.init
46
47# List of supported MIME types from Bandwidth - https://support.bandwidth.com/hc/en-us/articles/360014128994-What-MMS-file-types-are-supported-
48MMS_MIME_TYPES = [
49 "application/json",
50 "application/ogg",
51 "application/pdf",
52 "application/rtf",
53 "application/zip",
54 "application/x-tar",
55 "application/xml",
56 "application/gzip",
57 "application/x-bzip2",
58 "application/x-gzip",
59 "application/smil",
60 "application/javascript",
61 "audio/mp4",
62 "audio/mpeg",
63 "audio/ogg",
64 "audio/flac",
65 "audio/webm",
66 "audio/wav",
67 "audio/amr",
68 "audio/3gpp",
69 "image/bmp",
70 "image/gif",
71 "image/jpeg",
72 "image/pjpeg",
73 "image/png",
74 "image/svg+xml",
75 "image/tiff",
76 "image/webp",
77 "image/x-icon",
78 "text/css",
79 "text/csv",
80 "text/calendar",
81 "text/plain",
82 "text/javascript",
83 "text/vcard",
84 "text/vnd.wap.wml",
85 "text/xml",
86 "video/avi",
87 "video/mp4",
88 "video/mpeg",
89 "video/ogg",
90 "video/quicktime",
91 "video/webm",
92 "video/x-ms-wmv",
93 "video/x-flv"
94]
95
96def panic(e)
97 Sentry.capture_exception(e)
98 puts "Shutting down gateway due to exception: #{e.message}"
99 puts e.backtrace
100 SGXbwmsgsv2.shutdown
101 puts 'Gateway has terminated.'
102 EM.stop
103end
104
105EM.error_handler(&method(:panic))
106
107def extract_shortcode(dest)
108 num, context = dest.split(';', 2)
109 num if context == 'phone-context=ca-us.phone-context.soprani.ca'
110end
111
112def anonymous_tel?(dest)
113 dest.split(';', 2)[1] == 'phone-context=anonymous.phone-context.soprani.ca'
114end
115
116class SGXClient < Blather::Client
117 def handle_data(stanza)
118 promise = EMPromise.resolve(nil).then {
119 with_sentry(stanza) do |scope|
120 super
121 rescue StandardError => e
122 handle_error(scope, stanza, e)
123 end
124 }.catch { |e| panic(e) }
125 promise.sync if ENV["ENV"] == "test"
126 promise
127 end
128
129 # Override the default call_handler to syncify during testing.
130 def call_handler(handler, guards, stanza)
131 result = if guards.first.respond_to?(:to_str)
132 found = stanza.find(*guards)
133 throw :pass if found.empty?
134
135 handler.call(stanza, found)
136 else
137 throw :pass if guarded?(guards, stanza)
138
139 handler.call(stanza)
140 end
141
142 # Up to here, identical to upstream impl
143
144 return result unless result.is_a?(Promise)
145
146 result.sync if ENV["ENV"] == "test"
147 result
148 end
149
150protected
151
152 def with_sentry(stanza)
153 Sentry.clone_hub_to_current_thread
154
155 Sentry.with_scope do |scope|
156 setup_scope(stanza, scope)
157 yield scope
158 ensure
159 scope.get_transaction&.then do |tx|
160 tx.set_status("ok") unless tx.status
161 tx.finish
162 end
163 end
164 end
165
166 def setup_scope(stanza, scope)
167 name = stanza.respond_to?(:node) ? stanza.node : stanza.name
168 scope.clear_breadcrumbs
169 scope.set_transaction_name(name)
170 scope.set_user(jid: stanza.from&.stripped.to_s)
171
172 transaction = Sentry.start_transaction(
173 name: name,
174 op: "blather.handle_data"
175 )
176 scope.set_span(transaction) if transaction
177 end
178
179 def handle_error(scope, stanza, e)
180 puts "Error raised during #{scope.transaction_name}: #{e.class}"
181 puts e.message
182 puts e.backtrace
183 Sentry.capture_exception(e) unless e.is_a?(Sentry::Error)
184 scope.get_transaction&.set_status("internal_error")
185 return if e.respond_to?(:replied?) && e.replied?
186
187 SGXbwmsgsv2.write_to_stream stanza.as_error("internal-server-error", :cancel)
188 end
189end
190
191# TODO: keep in sync with jmp-acct_bot.rb, and eventually put in common location
192module CatapultSettingFlagBits
193 VOICEMAIL_TRANSCRIPTION_DISABLED = 0
194 MMS_ON_OOB_URL = 1
195end
196
197module SGXbwmsgsv2
198 extend Blather::DSL
199
200 @registration_repo = RegistrationRepo.new
201 @client = SGXClient.new
202 @gateway_features = [
203 "http://jabber.org/protocol/disco#info",
204 "http://jabber.org/protocol/address/",
205 "jabber:iq:register",
206 "http://jabber.org/protocol/commands"
207 ]
208
209 def self.run
210 # TODO: read/save ARGV[7] creds to local variables
211 client.run
212 end
213
214 # so classes outside this module can write messages, too
215 def self.write(stanza)
216 client.write(stanza)
217 end
218
219 def self.before_handler(type, *guards, &block)
220 client.register_handler_before(type, *guards, &block)
221 end
222
223 def self.send_media(from, to, media_url, desc=nil, subject=nil, m=nil)
224 # we assume media_url is one of these (always the case so far):
225 # https://messaging.bandwidth.com/api/v2/users/[u]/media/[path]
226
227 puts 'ORIG_URL: ' + media_url
228 usr = to
229 if media_url.start_with?('https://messaging.bandwidth.com/api/v2/users/')
230 pth = media_url.split('/', 9)[8]
231 # the caller must guarantee that 'to' is a bare JID
232 media_url = ARGV[6] + WEBrick::HTTPUtils.escape(usr) + '/' + pth
233 puts 'PROX_URL: ' + media_url
234 end
235
236 msg = m ? m.copy : Blather::Stanza::Message.new(to, "")
237 msg.from = from
238 msg.subject = subject if subject
239
240 # provide URL in XEP-0066 (OOB) fashion
241 x = Nokogiri::XML::Node.new 'x', msg.document
242 x['xmlns'] = 'jabber:x:oob'
243
244 urln = Nokogiri::XML::Node.new 'url', msg.document
245 urlc = Nokogiri::XML::Text.new media_url, msg.document
246 urln.add_child(urlc)
247 x.add_child(urln)
248
249 if desc
250 descn = Nokogiri::XML::Node.new('desc', msg.document)
251 descc = Nokogiri::XML::Text.new(desc, msg.document)
252 descn.add_child(descc)
253 x.add_child(descn)
254 end
255
256 msg.add_child(x)
257
258 write(msg)
259 rescue Exception => e
260 panic(e)
261 end
262
263 setup ARGV[0], ARGV[1], ARGV[2], ARGV[3], nil, nil, async: true
264
265 def self.pass_on_message(m, users_num, jid)
266 # setup delivery receipt; similar to a reply
267 rcpt = ReceiptMessage.new(m.from.stripped)
268 rcpt.from = m.to
269
270 # pass original message (before sending receipt)
271 m.to = jid
272 m.from = "#{users_num}@#{ARGV[0]}"
273
274 puts 'XRESPONSE0: ' + m.inspect
275 write_to_stream m
276
277 # send a delivery receipt back to the sender
278 # TODO: send only when requested per XEP-0184
279 # TODO: pass receipts from target if supported
280
281 # TODO: put in member/instance variable
282 rcpt['id'] = SecureRandom.uuid
283 rcvd = Nokogiri::XML::Node.new 'received', rcpt.document
284 rcvd['xmlns'] = 'urn:xmpp:receipts'
285 rcvd['id'] = m.id
286 rcpt.add_child(rcvd)
287
288 puts 'XRESPONSE1: ' + rcpt.inspect
289 write_to_stream rcpt
290 end
291
292 def self.call_catapult(
293 token, secret, m, pth, body=nil,
294 head={}, code=[200], respond_with=:body
295 )
296 # pth looks like one of:
297 # "api/v2/users/#{user_id}/[endpoint_name]"
298
299 url_prefix = ''
300
301 # TODO: need to make a separate thing for voice.bw.c eventually
302 if pth.start_with? 'api/v2/users'
303 url_prefix = 'https://messaging.bandwidth.com/'
304 end
305
306 EM::HttpRequest.new(
307 url_prefix + pth
308 ).public_send(
309 m,
310 head: {
311 'Authorization' => [token, secret]
312 }.merge(head),
313 body: body
314 ).then { |http|
315 puts "API response to send: #{http.response} with code"\
316 " response.code #{http.response_header.status}"
317
318 if code.include?(http.response_header.status)
319 case respond_with
320 when :body
321 http.response
322 when :headers
323 http.response_header
324 else
325 http
326 end
327 else
328 EMPromise.reject(
329 BandwidthError.for(http.response_header.status, http.response)
330 )
331 end
332 }
333 end
334
335 def self.to_catapult_possible_oob(s, num_dest, user_id, token, secret,
336 usern)
337 un = s.at("oob|x > oob|url", oob: "jabber:x:oob")
338 unless un
339 puts "MMSOOB: no url node found so process as normal"
340 return to_catapult(s, nil, num_dest, user_id, token,
341 secret, usern)
342 end
343 puts "MMSOOB: found a url node - checking if to make MMS..."
344
345 body = s.respond_to?(:body) ? s.body : ''
346 EM::HttpRequest.new(un.text, tls: { verify_peer: true }).head.then { |http|
347 # If content is too large, or MIME type is not supported, place the link inside the body and do not send MMS.
348 if http.response_header["CONTENT_LENGTH"].to_i > 3500000 ||
349 !MMS_MIME_TYPES.include?(http.response_header["CONTENT_TYPE"])
350 unless body.include?(un.text)
351 s.body = body.empty? ? un.text : "#{body}\n#{un.text}"
352 end
353 to_catapult(s, nil, num_dest, user_id, token, secret, usern)
354 else # If size is less than ~3.5MB, strip the link from the body and attach media in the body.
355 # some clients send URI in both body & <url/> so delete
356 s.body = body.sub(/\s*#{Regexp.escape(un.text)}\s*$/, '')
357
358 puts "MMSOOB: url text is '#{un.text}'"
359 puts "MMSOOB: the body is '#{body.to_s.strip}'"
360
361 puts "MMSOOB: sending MMS since found OOB & user asked"
362 to_catapult(s, un.text, num_dest, user_id, token, secret, usern)
363 end
364 }
365 end
366
367 def self.to_catapult(s, murl, num_dest, user_id, token, secret, usern)
368 body = s.respond_to?(:body) ? s.body.to_s : ''
369 if murl.to_s.empty? && body.strip.empty?
370 return EMPromise.reject(
371 [:modify, 'policy-violation']
372 )
373 end
374
375 segment_size = body.ascii_only? ? 160 : 70
376 if !murl && ENV["MMS_PATH"] && body.length > segment_size*3
377 file = Multibases.pack(
378 'base58btc',
379 Multihashes.encode(Digest::SHA256.digest(body), "sha2-256")
380 ).to_s
381 File.open("#{ENV['MMS_PATH']}/#{file}", "w") { |fh| fh.write body }
382 murl = "#{ENV['MMS_URL']}/#{file}.txt"
383 body = ""
384 end
385
386 extra = {}
387 extra[:media] = murl if murl
388
389 call_catapult(
390 token,
391 secret,
392 :post,
393 "api/v2/users/#{user_id}/messages",
394 JSON.dump(extra.merge(
395 from: usern,
396 to: num_dest,
397 text: body,
398 applicationId: ARGV[4],
399 tag:
400 # callbacks need id and resourcepart
401 WEBrick::HTTPUtils.escape(s.id.to_s) +
402 ' ' +
403 WEBrick::HTTPUtils.escape(
404 s.from.resource.to_s
405 )
406 )),
407 {'Content-Type' => 'application/json'},
408 [201]
409 ).catch { |e|
410 EMPromise.reject(
411 [:cancel, 'internal-server-error', e.message]
412 )
413 }
414 end
415
416 def self.validate_num(m)
417 # if sent to SGX domain use https://wiki.soprani.ca/SGX/GroupMMS
418 if m.to == ARGV[0]
419 an = m.children.find { |v| v.element_name == "addresses" }
420 if not an
421 return EMPromise.reject(
422 [:cancel, 'item-not-found']
423 )
424 end
425 puts "ADRXEP: found an addresses node - iterate addrs.."
426
427 nums = []
428 an.children.each do |e|
429 num = ''
430 type = ''
431 e.attributes.each do |c|
432 if c[0] == 'type'
433 if c[1] != 'to'
434 # TODO: error
435 end
436 type = c[1].to_s
437 elsif c[0] == 'uri'
438 if !c[1].to_s.start_with? 'sms:'
439 # TODO: error
440 end
441 num = c[1].to_s[4..-1]
442 # TODO: confirm num validates
443 # TODO: else, error - unexpected name
444 end
445 end
446 if num.empty? or type.empty?
447 # TODO: error
448 end
449 nums << num
450 end
451 return nums
452 end
453
454 # if not sent to SGX domain, then assume destination is in 'to'
455 EMPromise.resolve(m.to.node.to_s).then { |num_dest|
456 if num_dest =~ /\A\+?[0-9]+(?:;.*)?\Z/
457 next num_dest if num_dest[0] == '+'
458
459 shortcode = extract_shortcode(num_dest)
460 next shortcode if shortcode
461 end
462
463 if anonymous_tel?(num_dest)
464 EMPromise.reject([:cancel, 'gone'])
465 else
466 # TODO: text re num not (yet) supportd/implmentd
467 EMPromise.reject([:cancel, 'item-not-found'])
468 end
469 }
470 end
471
472 def self.fetch_catapult_cred_for(jid)
473 @registration_repo.find(jid).then { |creds|
474 if creds.length < 4
475 # TODO: add text re credentials not registered
476 EMPromise.reject(
477 [:auth, 'registration-required']
478 )
479 else
480 creds
481 end
482 }
483 end
484
485 message :error? do |m|
486 # TODO: report it somewhere/somehow - eat for now so no err loop
487 puts "EATERROR1: #{m.inspect}"
488 end
489
490 message :body do |m|
491 EMPromise.all([
492 validate_num(m),
493 fetch_catapult_cred_for(m.from)
494 ]).then { |(num_dest, creds)|
495 @registration_repo.find_jid(num_dest).then { |jid|
496 [jid, num_dest] + creds
497 }
498 }.then { |(jid, num_dest, *creds)|
499 if jid
500 @registration_repo.find(jid).then { |other_user|
501 [jid, num_dest] + creds + [other_user.first]
502 }
503 else
504 [jid, num_dest] + creds + [nil]
505 end
506 }.then { |(jid, num_dest, *creds, other_user)|
507 # if destination user is in the system pass on directly
508 if other_user and not other_user.start_with? 'u-'
509 pass_on_message(m, creds.last, jid)
510 else
511 to_catapult_possible_oob(m, num_dest, *creds)
512 end
513 }.catch { |e|
514 if e.is_a?(Array) && (e.length == 2 || e.length == 3)
515 write_to_stream m.as_error(e[1], e[0], e[2])
516 else
517 EMPromise.reject(e)
518 end
519 }
520 end
521
522 def self.user_cap_identities
523 [{category: 'client', type: 'sms'}]
524 end
525
526 # TODO: must re-add stuff so can do ad-hoc commands
527 def self.user_cap_features
528 ["urn:xmpp:receipts"]
529 end
530
531 def self.add_gateway_feature(feature)
532 @gateway_features << feature
533 @gateway_features.uniq!
534 end
535
536 subscription :request? do |p|
537 puts "PRESENCE1: #{p.inspect}"
538
539 # subscriptions are allowed from anyone - send reply immediately
540 msg = Blather::Stanza::Presence.new
541 msg.to = p.from
542 msg.from = p.to
543 msg.type = :subscribed
544
545 puts 'RESPONSE5a: ' + msg.inspect
546 write_to_stream msg
547
548 # send a <presence> immediately; not automatically probed for it
549 # TODO: refactor so no "presence :probe? do |p|" duplicate below
550 caps = Blather::Stanza::Capabilities.new
551 # TODO: user a better node URI (?)
552 caps.node = 'http://catapult.sgx.soprani.ca/'
553 caps.identities = user_cap_identities
554 caps.features = user_cap_features
555
556 msg = caps.c
557 msg.to = p.from
558 msg.from = p.to.to_s + '/sgx'
559
560 puts 'RESPONSE5b: ' + msg.inspect
561 write_to_stream msg
562
563 # need to subscribe back so Conversations displays images inline
564 msg = Blather::Stanza::Presence.new
565 msg.to = p.from.to_s.split('/', 2)[0]
566 msg.from = p.to.to_s.split('/', 2)[0]
567 msg.type = :subscribe
568
569 puts 'RESPONSE5c: ' + msg.inspect
570 write_to_stream msg
571 end
572
573 presence :probe? do |p|
574 puts 'PRESENCE2: ' + p.inspect
575
576 caps = Blather::Stanza::Capabilities.new
577 # TODO: user a better node URI (?)
578 caps.node = 'http://catapult.sgx.soprani.ca/'
579 caps.identities = user_cap_identities
580 caps.features = user_cap_features
581
582 msg = caps.c
583 msg.to = p.from
584 msg.from = p.to.to_s + '/sgx'
585
586 puts 'RESPONSE6: ' + msg.inspect
587 write_to_stream msg
588 end
589
590 disco_items(
591 to: Blather::JID.new(ARGV[0]),
592 node: "http://jabber.org/protocol/commands"
593 ) do |i|
594 fetch_catapult_cred_for(i.from).then { |creds|
595 BandwidthTNOptions.tn_eligible_for_port_out_pin?(creds).then { |eligible|
596 reply = i.reply
597 reply.node = 'http://jabber.org/protocol/commands'
598
599 if eligible
600 reply.items = [
601 Blather::Stanza::DiscoItems::Item.new(
602 i.to,
603 'set-port-out-pin',
604 'Set Port-Out PIN'
605 )
606 ]
607 else
608 reply.items = []
609 end
610
611 puts 'RESPONSE_CMD_DISCO: ' + reply.inspect
612 write_to_stream reply
613 }
614 }.catch { |e|
615 if e.is_a?(Array) && [2, 3].include?(e.length)
616 write_to_stream i.as_error(e[1], e[0], e[2])
617 else
618 EMPromise.reject(e)
619 end
620 }
621 end
622
623 iq '/iq/ns:query', ns: 'http://jabber.org/protocol/disco#info' do |i|
624 # TODO: return error if i.type is :set - if it is :reply or
625 # :error it should be ignored (as the below does currently);
626 # review specification to see how to handle other type values
627 if i.type != :get
628 puts 'DISCO iq rcvd, of non-get type "' + i.type.to_s +
629 '" for message "' + i.inspect + '"; ignoring...'
630 next
631 end
632
633 # respond to capabilities request for an sgx-bwmsgsv2 number JID
634 if i.to.node
635 # TODO: confirm the node URL is expected using below
636 #puts "XR[node]: #{xpath_result[0]['node']}"
637
638 msg = i.reply
639 msg.node = i.node
640 msg.identities = user_cap_identities
641 msg.features = user_cap_features
642
643 puts 'RESPONSE7: ' + msg.inspect
644 write_to_stream msg
645 next
646 end
647
648 # respond to capabilities request for sgx-bwmsgsv2 itself
649 msg = i.reply
650 msg.node = i.node
651 msg.identities = [{
652 name: 'Soprani.ca Gateway to XMPP - Bandwidth API V2',
653 type: 'sms', category: 'gateway'
654 }]
655 msg.features = @gateway_features
656 write_to_stream msg
657 end
658
659 def self.check_then_register(i, *creds)
660 @registration_repo
661 .put(i.from, *creds)
662 .catch_only(RegistrationRepo::Conflict) { |e|
663 EMPromise.reject([:cancel, 'conflict', e.message])
664 }.then {
665 write_to_stream i.reply
666 }
667 end
668
669 def self.creds_from_registration_query(i)
670 if i.query.find_first("./ns:x", ns: "jabber:x:data")
671 [
672 i.form.field("nick")&.value,
673 i.form.field("username")&.value,
674 i.form.field("password")&.value,
675 i.form.field("phone")&.value
676 ]
677 else
678 [i.nick, i.username, i.password, i.phone]
679 end
680 end
681
682 def self.process_registration(i)
683 EMPromise.resolve(nil).then {
684 if i.remove?
685 @registration_repo.delete(i.from).then do
686 write_to_stream i.reply
687 EMPromise.reject(:done)
688 end
689 else
690 creds_from_registration_query(i)
691 end
692 }.then { |user_id, api_token, api_secret, phone_num|
693 if phone_num && phone_num[0] == '+'
694 [user_id, api_token, api_secret, phone_num]
695 else
696 # TODO: add text re number not (yet) supported
697 EMPromise.reject([:cancel, 'item-not-found'])
698 end
699 }.then { |user_id, api_token, api_secret, phone_num|
700 # TODO: find way to verify #{phone_num}, too
701 call_catapult(
702 api_token,
703 api_secret,
704 :get,
705 "api/v2/users/#{user_id}/media"
706 ).then { |response|
707 JSON.parse(response)
708 # TODO: confirm response is array - could be empty
709
710 puts "register got str #{response.to_s[0..999]}"
711
712 check_then_register(
713 i,
714 user_id,
715 api_token,
716 api_secret,
717 phone_num
718 )
719 }
720 }.catch_only(BandwidthError) { |e|
721 EMPromise.reject(case e.code
722 when 401
723 # TODO: add text re bad credentials
724 [:auth, 'not-authorized']
725 when 404
726 # TODO: add text re number not found or disabled
727 [:cancel, 'item-not-found']
728 else
729 [:modify, 'not-acceptable']
730 end)
731 }
732 end
733
734 def self.registration_form(orig, existing_number=nil)
735 orig.registered = !!existing_number
736
737 # TODO: update "User Id" x2 below (to "accountId"?), and others?
738 orig.instructions = "Enter the information from your Account "\
739 "page as well as the Phone Number\nin your "\
740 "account you want to use (ie. '+12345678901')"\
741 ".\nUser Id is nick, API Token is username, "\
742 "API Secret is password, Phone Number is phone"\
743 ".\n\nThe source code for this gateway is at "\
744 "https://gitlab.com/soprani.ca/sgx-bwmsgsv2 ."\
745 "\nCopyright (C) 2017-2020 Denver Gingerich "\
746 "and others, licensed under AGPLv3+."
747 orig.nick = ""
748 orig.username = ""
749 orig.password = ""
750 orig.phone = existing_number.to_s
751
752 orig.form.fields = [
753 {
754 required: true, type: :"text-single",
755 label: 'User Id', var: 'nick'
756 },
757 {
758 required: true, type: :"text-single",
759 label: 'API Token', var: 'username'
760 },
761 {
762 required: true, type: :"text-private",
763 label: 'API Secret', var: 'password'
764 },
765 {
766 required: true, type: :"text-single",
767 label: 'Phone Number', var: 'phone',
768 value: existing_number.to_s
769 }
770 ]
771 orig.form.title = 'Register for '\
772 'Soprani.ca Gateway to XMPP - Bandwidth API V2'
773 orig.form.instructions = "Enter the details from your Account "\
774 "page as well as the Phone Number\nin your "\
775 "account you want to use (ie. '+12345678901')"\
776 ".\n\nThe source code for this gateway is at "\
777 "https://gitlab.com/soprani.ca/sgx-bwmsgsv2 ."\
778 "\nCopyright (C) 2017-2020 Denver Gingerich "\
779 "and others, licensed under AGPLv3+."
780
781 orig
782 end
783
784 ibr do |i|
785 puts "IQ: #{i.inspect}"
786
787 case i.type
788 when :set
789 process_registration(i)
790 when :get
791 bare_jid = i.from.stripped
792 @registration_repo.find(bare_jid).then { |creds|
793 reply = registration_form(i.reply, creds.last)
794 puts "RESPONSE2: #{reply.inspect}"
795 write_to_stream reply
796 }
797 else
798 # Unknown IQ, ignore for now
799 EMPromise.reject(:done)
800 end.catch { |e|
801 if e.is_a?(Array) && (e.length == 2 || e.length == 3)
802 write_to_stream i.as_error(e[1], e[0], e[2])
803 elsif e != :done
804 EMPromise.reject(e)
805 end
806 }.catch(&method(:panic))
807 end
808
809 command :execute?, node: "set-port-out-pin", sessionid: nil do |iq|
810 # Ensure user is registered, but discard their credentials
811 # because we're just showing them a form.
812 fetch_catapult_cred_for(iq.from).then { |_creds|
813 reply = iq.reply
814 reply.node = 'set-port-out-pin'
815 reply.sessionid = SecureRandom.uuid
816 reply.status = :executing
817
818 form = Blather::Stanza::X.find_or_create(reply.command)
819 form.type = "form"
820 form.fields = [
821 {
822 var: 'pin',
823 type: 'text-private',
824 label: 'Port-Out PIN',
825 required: true
826 },
827 {
828 var: 'confirm_pin',
829 type: 'text-private',
830 label: 'Confirm PIN',
831 required: true
832 }
833 ]
834
835 reply.command.add_child(form)
836 reply.allowed_actions = [:complete]
837
838 puts "RESPONSE_CMD_FORM: #{reply.inspect}"
839 write_to_stream reply
840 }.catch { |e|
841 if e.is_a?(Array) && [2, 3].include?(e.length)
842 write_to_stream iq.as_error(e[1], e[0], e[2])
843 else
844 EMPromise.reject(e)
845 end
846 }.catch(&method(:panic))
847 end
848
849 command :complete?, node: "set-port-out-pin", sessionid: /./ do |iq|
850 pin = iq.form.field('pin')&.value
851 confirm_pin = iq.form.field('confirm_pin')&.value
852
853 if pin.nil? || confirm_pin.nil?
854 write_to_stream iq.as_error(
855 'bad-request',
856 :modify,
857 'PIN fields are required'
858 )
859 next
860 end
861
862 if pin != confirm_pin
863 write_to_stream iq.as_error(
864 'bad-request',
865 :modify,
866 'PIN confirmation does not match'
867 )
868 next
869 end
870
871 if pin !~ /\A[a-zA-Z0-9]{4,10}\z/
872 write_to_stream iq.as_error(
873 'bad-request',
874 :modify,
875 'PIN must be 4-10 alphanumeric characters'
876 )
877 next
878 end
879
880 fetch_catapult_cred_for(iq.from).then { |creds|
881 BandwidthTNOptions.set_port_out_pin(creds, pin).then {
882 reply = iq.reply
883 reply.node = 'set-port-out-pin'
884 reply.sessionid = iq.sessionid
885 reply.status = :completed
886 reply.note_type = :info
887 reply.note_text = 'Port-out PIN has been set successfully.'
888
889 write_to_stream reply
890 }.catch { |e|
891 reply = iq.reply
892 reply.node = 'set-port-out-pin'
893 reply.sessionid = iq.sessionid
894 reply.status = :completed
895 reply.note_type = :error
896 error_msg = if e.respond_to?(:message) && e.message.include?('not valid')
897 "Invalid phone number format. "\
898 "Please check your registered phone number."
899 elsif e.respond_to?(:message) && e.message.include?('ErrorCode')
900 "Bandwidth API error: #{e.message}"
901 else
902 "Failed to set port-out PIN. Please try again later."
903 end
904 reply.note_text = error_msg
905
906 write_to_stream reply
907 }
908 }.catch { |e|
909 if e.is_a?(Array) && [2, 3].include?(e.length)
910 write_to_stream iq.as_error(e[1], e[0], e[2])
911 else
912 EMPromise.reject(e)
913 end
914 }.catch(&method(:panic))
915 end
916
917 iq type: [:get, :set] do |iq|
918 write_to_stream(Blather::StanzaError.new(
919 iq,
920 'feature-not-implemented',
921 :cancel
922 ))
923 end
924end
925
926class ReceiptMessage < Blather::Stanza
927 def self.new(to=nil)
928 node = super :message
929 node.to = to
930 node
931 end
932end
933
934class WebhookHandler < Goliath::API
935 use Sentry::Rack::CaptureExceptions
936 use Goliath::Rack::Params
937
938 def response(env)
939 @registration_repo = RegistrationRepo.new
940 # TODO: add timestamp grab here, and MUST include ./tai version
941
942 puts 'ENV: ' + env.reject { |k| k == 'params' }.to_s
943
944 if params.empty?
945 puts 'PARAMS empty!'
946 return [200, {}, "OK"]
947 end
948
949 if env['REQUEST_URI'] != '/'
950 puts 'BADREQUEST1: non-/ request "' +
951 env['REQUEST_URI'] + '", method "' +
952 env['REQUEST_METHOD'] + '"'
953 return [200, {}, "OK"]
954 end
955
956 if env['REQUEST_METHOD'] != 'POST'
957 puts 'BADREQUEST2: non-POST request; URI: "' +
958 env['REQUEST_URI'] + '", method "' +
959 env['REQUEST_METHOD'] + '"'
960 return [200, {}, "OK"]
961 end
962
963 # TODO: process each message in list, not just first one
964 jparams = params.dig('_json', 0, 'message')
965 type = params.dig('_json', 0, 'type')
966
967 return [400, {}, "Missing params\n"] unless jparams && type
968
969 users_num, others_num = if jparams['direction'] == 'in'
970 [jparams['owner'], jparams['from']]
971 elsif jparams['direction'] == 'out'
972 [jparams['from'], jparams['owner']]
973 else
974 puts "big prob: '#{jparams['direction']}'"
975 return [400, {}, "OK"]
976 end
977
978 jparams['to'].reject! { |num|
979 num == users_num || num == others_num
980 }
981
982 return [400, {}, "Missing params\n"] unless users_num && others_num
983 return [400, {}, "Missing params\n"] unless jparams['to'].is_a?(Array)
984
985 puts "BODY - messageId: #{jparams['id']}" \
986 ", eventType: #{type}" \
987 ", time: #{jparams['time']}" \
988 ", direction: #{jparams['direction']}" \
989 ", deliveryState: #{jparams['deliveryState'] || 'NONE'}" \
990 ", errorCode: #{jparams['errorCode'] || 'NONE'}" \
991 ", description: #{jparams['description'] || 'NONE'}" \
992 ", tag: #{jparams['tag'] || 'NONE'}" \
993 ", media: #{jparams['media'] || 'NONE'}"
994
995 if others_num[0] != '+'
996 # TODO: check that others_num actually a shortcode first
997 others_num +=
998 ';phone-context=ca-us.phone-context.soprani.ca'
999 end
1000
1001 bare_jid = @registration_repo.find_jid(users_num).sync
1002
1003 if !bare_jid
1004 puts "jid_key for (#{users_num}) DNE; BW API misconfigured?"
1005
1006 return [403, {}, "Customer not found\n"]
1007 end
1008
1009 msg = nil
1010 case jparams['direction']
1011 when 'in'
1012 text = ''
1013 case type
1014 when 'sms'
1015 text = jparams['text']
1016 when 'mms'
1017 has_media = false
1018
1019 if jparams['text'].empty?
1020 if not has_media
1021 text = '[suspected group msg '\
1022 'with no text (odd)]'
1023 end
1024 else
1025 text = if has_media
1026 # TODO: write/use a caption XEP
1027 jparams['text']
1028 else
1029 '[suspected group msg '\
1030 '(recipient list not '\
1031 'available) with '\
1032 'following text] ' +
1033 jparams['text']
1034 end
1035 end
1036
1037 # ie. if text param non-empty or had no media
1038 if not text.empty?
1039 msg = Blather::Stanza::Message.new(
1040 bare_jid, text)
1041 msg.from = others_num + '@' + ARGV[0]
1042 SGXbwmsgsv2.write(msg)
1043 end
1044
1045 return [200, {}, "OK"]
1046 when 'message-received'
1047 # TODO: handle group chat, and fix above
1048 text = jparams['text']
1049
1050 if jparams['to'].length > 1
1051 msg = Blather::Stanza::Message.new(
1052 Blather::JID.new(bare_jid).domain,
1053 text
1054 )
1055
1056 addrs = Nokogiri::XML::Node.new(
1057 'addresses', msg.document)
1058 addrs['xmlns'] = 'http://jabber.org/' \
1059 'protocol/address'
1060
1061 addr1 = Nokogiri::XML::Node.new(
1062 'address', msg.document)
1063 addr1['type'] = 'to'
1064 addr1['jid'] = bare_jid
1065 addrs.add_child(addr1)
1066
1067 jparams['to'].each do |receiver|
1068 addrn = Nokogiri::XML::Node.new(
1069 'address', msg.document)
1070 addrn['type'] = 'to'
1071 addrn['uri'] = "sms:#{receiver}"
1072 addrn['delivered'] = 'true'
1073 addrs.add_child(addrn)
1074 end
1075
1076 msg.add_child(addrs)
1077
1078 # TODO: delete
1079 puts "RESPONSE9: #{msg.inspect}"
1080 end
1081
1082 Array(jparams['media']).each do |media_url|
1083 unless media_url.end_with?(
1084 '.smil', '.txt', '.xml'
1085 )
1086 has_media = true
1087 SGXbwmsgsv2.send_media(
1088 others_num + '@' +
1089 ARGV[0],
1090 bare_jid, media_url,
1091 nil, nil, msg
1092 )
1093 end
1094 end
1095 else
1096 text = "unknown type (#{type})"\
1097 " with text: " + jparams['text']
1098
1099 # TODO: log/notify of this properly
1100 puts text
1101 end
1102
1103 if not msg
1104 msg = Blather::Stanza::Message.new(bare_jid, text)
1105 end
1106 else # per prior switch, this is: jparams['direction'] == 'out'
1107 tag_parts = jparams['tag'].split(/ /, 2)
1108 id = WEBrick::HTTPUtils.unescape(tag_parts[0])
1109 resourcepart = WEBrick::HTTPUtils.unescape(tag_parts[1])
1110
1111 # TODO: remove this hack
1112 if jparams['to'].length > 1
1113 puts "WARN! group no rcpt: #{users_num}"
1114 return [200, {}, "OK"]
1115 end
1116
1117 case type
1118 when 'message-failed'
1119 # create a bare message like the one user sent
1120 msg = Blather::Stanza::Message.new(
1121 others_num + '@' + ARGV[0])
1122 msg.from = bare_jid + '/' + resourcepart
1123 msg['id'] = id
1124
1125 # TODO: add 'errorCode' and/or 'description' val
1126 # create an error reply to the bare message
1127 msg = msg.as_error(
1128 'recipient-unavailable',
1129 :wait,
1130 jparams['description']
1131 )
1132
1133 # TODO: make prettier: this should be done above
1134 others_num = params['_json'][0]['to']
1135 when 'message-delivered'
1136
1137 msg = ReceiptMessage.new(bare_jid)
1138
1139 # TODO: put in member/instance variable
1140 msg['id'] = SecureRandom.uuid
1141
1142 # TODO: send only when requested per XEP-0184
1143 rcvd = Nokogiri::XML::Node.new(
1144 'received',
1145 msg.document
1146 )
1147 rcvd['xmlns'] = 'urn:xmpp:receipts'
1148 rcvd['id'] = id
1149 msg.add_child(rcvd)
1150
1151 # TODO: make prettier: this should be done above
1152 others_num = params['_json'][0]['to']
1153 else
1154 # TODO: notify somehow of unknown state receivd?
1155 puts "message with id #{id} has "\
1156 "other type #{type}"
1157 return [200, {}, "OK"]
1158 end
1159
1160 puts "RESPONSE4: #{msg.inspect}"
1161 end
1162
1163 msg.from = others_num + '@' + ARGV[0]
1164 SGXbwmsgsv2.write(msg)
1165
1166 [200, {}, "OK"]
1167 rescue Exception => e
1168 Sentry.capture_exception(e)
1169 puts 'Shutting down gateway due to exception 013: ' + e.message
1170 SGXbwmsgsv2.shutdown
1171 puts 'Gateway has terminated.'
1172 EM.stop
1173 end
1174end
1175
1176at_exit do
1177 $stdout.sync = true
1178
1179 puts "Soprani.ca/SMS Gateway for XMPP - Bandwidth API V2\n"\
1180 "==>> last commit of this version is " + `git rev-parse HEAD` + "\n"
1181
1182 if ARGV.size != 7
1183 puts "Usage: sgx-bwmsgsv2.rb <component_jid> "\
1184 "<component_password> <server_hostname> "\
1185 "<server_port> <application_id> "\
1186 "<http_listen_port> <mms_proxy_prefix_url>"
1187 exit 0
1188 end
1189
1190 t = Time.now
1191 puts "LOG %d.%09d: starting...\n\n" % [t.to_i, t.nsec]
1192
1193 EM.run do
1194 REDIS = EM::Hiredis.connect
1195
1196 SGXbwmsgsv2.run
1197
1198 # required when using Prosody otherwise disconnects on 6-hour inactivity
1199 EM.add_periodic_timer(3600) do
1200 msg = Blather::Stanza::Iq::Ping.new(:get, 'localhost')
1201 msg.from = ARGV[0]
1202 SGXbwmsgsv2.write(msg)
1203 end
1204
1205 server = Goliath::Server.new('0.0.0.0', ARGV[5].to_i)
1206 server.api = WebhookHandler.new
1207 server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
1208 server.logger = Log4r::Logger.new('goliath')
1209 server.logger.add(Log4r::StdoutOutputter.new('console'))
1210 server.logger.level = Log4r::INFO
1211 server.start do
1212 ["INT", "TERM"].each do |sig|
1213 trap(sig) do
1214 EM.defer do
1215 puts 'Shutting down gateway...'
1216 SGXbwmsgsv2.shutdown
1217
1218 puts 'Gateway has terminated.'
1219 EM.stop
1220 end
1221 end
1222 end
1223 end
1224 end
1225end unless ENV['ENV'] == 'test'