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