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