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