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 fetch_catapult_cred_for(i.from).then { |creds|
545 BandwidthTNOptions.tn_eligible_for_port_out_pin?(creds).then { |eligible|
546 reply = i.reply
547 reply.node = 'http://jabber.org/protocol/commands'
548
549 if eligible
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 else
558 reply.items = []
559 end
560
561 puts 'RESPONSE_CMD_DISCO: ' + reply.inspect
562 write_to_stream reply
563 }
564 }.catch { |e|
565 if e.is_a?(Array) && [2, 3].include?(e.length)
566 write_to_stream i.as_error(e[1], e[0], e[2])
567 else
568 EMPromise.reject(e)
569 end
570 }
571 end
572
573 iq '/iq/ns:query', ns: 'http://jabber.org/protocol/disco#info' do |i|
574 # TODO: return error if i.type is :set - if it is :reply or
575 # :error it should be ignored (as the below does currently);
576 # review specification to see how to handle other type values
577 if i.type != :get
578 puts 'DISCO iq rcvd, of non-get type "' + i.type.to_s +
579 '" for message "' + i.inspect + '"; ignoring...'
580 next
581 end
582
583 # respond to capabilities request for an sgx-bwmsgsv2 number JID
584 if i.to.node
585 # TODO: confirm the node URL is expected using below
586 #puts "XR[node]: #{xpath_result[0]['node']}"
587
588 msg = i.reply
589 msg.node = i.node
590 msg.identities = user_cap_identities
591 msg.features = user_cap_features
592
593 puts 'RESPONSE7: ' + msg.inspect
594 write_to_stream msg
595 next
596 end
597
598 # respond to capabilities request for sgx-bwmsgsv2 itself
599 msg = i.reply
600 msg.node = i.node
601 msg.identities = [{
602 name: 'Soprani.ca Gateway to XMPP - Bandwidth API V2',
603 type: 'sms', category: 'gateway'
604 }]
605 msg.features = @gateway_features
606 write_to_stream msg
607 end
608
609 def self.check_then_register(i, *creds)
610 @registration_repo
611 .put(i.from, *creds)
612 .catch_only(RegistrationRepo::Conflict) { |e|
613 EMPromise.reject([:cancel, 'conflict', e.message])
614 }.then {
615 write_to_stream i.reply
616 }
617 end
618
619 def self.creds_from_registration_query(i)
620 if i.query.find_first("./ns:x", ns: "jabber:x:data")
621 [
622 i.form.field("nick")&.value,
623 i.form.field("username")&.value,
624 i.form.field("password")&.value,
625 i.form.field("phone")&.value
626 ]
627 else
628 [i.nick, i.username, i.password, i.phone]
629 end
630 end
631
632 def self.process_registration(i)
633 EMPromise.resolve(nil).then {
634 if i.remove?
635 @registration_repo.delete(i.from).then do
636 write_to_stream i.reply
637 EMPromise.reject(:done)
638 end
639 else
640 creds_from_registration_query(i)
641 end
642 }.then { |user_id, api_token, api_secret, phone_num|
643 if phone_num && phone_num[0] == '+'
644 [user_id, api_token, api_secret, phone_num]
645 else
646 # TODO: add text re number not (yet) supported
647 EMPromise.reject([:cancel, 'item-not-found'])
648 end
649 }.then { |user_id, api_token, api_secret, phone_num|
650 # TODO: find way to verify #{phone_num}, too
651 call_catapult(
652 api_token,
653 api_secret,
654 :get,
655 "api/v2/users/#{user_id}/media"
656 ).then { |response|
657 JSON.parse(response)
658 # TODO: confirm response is array - could be empty
659
660 puts "register got str #{response.to_s[0..999]}"
661
662 check_then_register(
663 i,
664 user_id,
665 api_token,
666 api_secret,
667 phone_num
668 )
669 }
670 }.catch_only(BandwidthError) { |e|
671 EMPromise.reject(case e.code
672 when 401
673 # TODO: add text re bad credentials
674 [:auth, 'not-authorized']
675 when 404
676 # TODO: add text re number not found or disabled
677 [:cancel, 'item-not-found']
678 else
679 [:modify, 'not-acceptable']
680 end)
681 }
682 end
683
684 def self.registration_form(orig, existing_number=nil)
685 orig.registered = !!existing_number
686
687 # TODO: update "User Id" x2 below (to "accountId"?), and others?
688 orig.instructions = "Enter the information from your Account "\
689 "page as well as the Phone Number\nin your "\
690 "account you want to use (ie. '+12345678901')"\
691 ".\nUser Id is nick, API Token is username, "\
692 "API Secret is password, Phone Number is phone"\
693 ".\n\nThe source code for this gateway is at "\
694 "https://gitlab.com/soprani.ca/sgx-bwmsgsv2 ."\
695 "\nCopyright (C) 2017-2020 Denver Gingerich "\
696 "and others, licensed under AGPLv3+."
697 orig.nick = ""
698 orig.username = ""
699 orig.password = ""
700 orig.phone = existing_number.to_s
701
702 orig.form.fields = [
703 {
704 required: true, type: :"text-single",
705 label: 'User Id', var: 'nick'
706 },
707 {
708 required: true, type: :"text-single",
709 label: 'API Token', var: 'username'
710 },
711 {
712 required: true, type: :"text-private",
713 label: 'API Secret', var: 'password'
714 },
715 {
716 required: true, type: :"text-single",
717 label: 'Phone Number', var: 'phone',
718 value: existing_number.to_s
719 }
720 ]
721 orig.form.title = 'Register for '\
722 'Soprani.ca Gateway to XMPP - Bandwidth API V2'
723 orig.form.instructions = "Enter the details from your Account "\
724 "page as well as the Phone Number\nin your "\
725 "account you want to use (ie. '+12345678901')"\
726 ".\n\nThe source code for this gateway is at "\
727 "https://gitlab.com/soprani.ca/sgx-bwmsgsv2 ."\
728 "\nCopyright (C) 2017-2020 Denver Gingerich "\
729 "and others, licensed under AGPLv3+."
730
731 orig
732 end
733
734 ibr do |i|
735 puts "IQ: #{i.inspect}"
736
737 case i.type
738 when :set
739 process_registration(i)
740 when :get
741 bare_jid = i.from.stripped
742 @registration_repo.find(bare_jid).then { |creds|
743 reply = registration_form(i.reply, creds.last)
744 puts "RESPONSE2: #{reply.inspect}"
745 write_to_stream reply
746 }
747 else
748 # Unknown IQ, ignore for now
749 EMPromise.reject(:done)
750 end.catch { |e|
751 if e.is_a?(Array) && (e.length == 2 || e.length == 3)
752 write_to_stream i.as_error(e[1], e[0], e[2])
753 elsif e != :done
754 EMPromise.reject(e)
755 end
756 }.catch(&method(:panic))
757 end
758
759 command :execute?, node: "set-port-out-pin", sessionid: nil do |iq|
760 # Ensure user is registered, but discard their credentials
761 # because we're just showing them a form.
762 fetch_catapult_cred_for(iq.from).then { |_creds|
763 reply = iq.reply
764 reply.node = 'set-port-out-pin'
765 reply.sessionid = SecureRandom.uuid
766 reply.status = :executing
767
768 form = Blather::Stanza::X.find_or_create(reply.command)
769 form.type = "form"
770 form.fields = [
771 {
772 var: 'pin',
773 type: 'text-private',
774 label: 'Port-Out PIN',
775 required: true
776 },
777 {
778 var: 'confirm_pin',
779 type: 'text-private',
780 label: 'Confirm PIN',
781 required: true
782 }
783 ]
784
785 reply.command.add_child(form)
786 reply.allowed_actions = [:complete]
787
788 puts "RESPONSE_CMD_FORM: #{reply.inspect}"
789 write_to_stream reply
790 }.catch { |e|
791 if e.is_a?(Array) && [2, 3].include?(e.length)
792 write_to_stream iq.as_error(e[1], e[0], e[2])
793 else
794 EMPromise.reject(e)
795 end
796 }.catch(&method(:panic))
797 end
798
799 command :complete?, node: "set-port-out-pin", sessionid: /./ do |iq|
800 pin = iq.form.field('pin')&.value
801 confirm_pin = iq.form.field('confirm_pin')&.value
802
803 if pin.nil? || confirm_pin.nil?
804 write_to_stream iq.as_error(
805 'bad-request',
806 :modify,
807 'PIN fields are required'
808 )
809 next
810 end
811
812 if pin != confirm_pin
813 write_to_stream iq.as_error(
814 'bad-request',
815 :modify,
816 'PIN confirmation does not match'
817 )
818 next
819 end
820
821 if pin !~ /\A[a-zA-Z0-9]{4,10}\z/
822 write_to_stream iq.as_error(
823 'bad-request',
824 :modify,
825 'PIN must be 4-10 alphanumeric characters'
826 )
827 next
828 end
829
830 fetch_catapult_cred_for(iq.from).then { |creds|
831 BandwidthTNOptions.set_port_out_pin(creds, pin).then {
832 reply = iq.reply
833 reply.node = 'set-port-out-pin'
834 reply.sessionid = iq.sessionid
835 reply.status = :completed
836 reply.note_type = :info
837 reply.note_text = 'Port-out PIN has been set successfully.'
838
839 write_to_stream reply
840 }.catch { |e|
841 reply = iq.reply
842 reply.node = 'set-port-out-pin'
843 reply.sessionid = iq.sessionid
844 reply.status = :completed
845 reply.note_type = :error
846 error_msg = if e.respond_to?(:message) && e.message.include?('not valid')
847 "Invalid phone number format. "\
848 "Please check your registered phone number."
849 elsif e.respond_to?(:message) && e.message.include?('ErrorCode')
850 "Bandwidth API error: #{e.message}"
851 else
852 "Failed to set port-out PIN. Please try again later."
853 end
854 reply.note_text = error_msg
855
856 write_to_stream reply
857 }
858 }.catch { |e|
859 if e.is_a?(Array) && [2, 3].include?(e.length)
860 write_to_stream iq.as_error(e[1], e[0], e[2])
861 else
862 EMPromise.reject(e)
863 end
864 }.catch(&method(:panic))
865 end
866
867 iq type: [:get, :set] do |iq|
868 write_to_stream(Blather::StanzaError.new(
869 iq,
870 'feature-not-implemented',
871 :cancel
872 ))
873 end
874end
875
876class ReceiptMessage < Blather::Stanza
877 def self.new(to=nil)
878 node = super :message
879 node.to = to
880 node
881 end
882end
883
884class WebhookHandler < Goliath::API
885 use Sentry::Rack::CaptureExceptions
886 use Goliath::Rack::Params
887
888 def response(env)
889 @registration_repo = RegistrationRepo.new
890 # TODO: add timestamp grab here, and MUST include ./tai version
891
892 puts 'ENV: ' + env.reject { |k| k == 'params' }.to_s
893
894 if params.empty?
895 puts 'PARAMS empty!'
896 return [200, {}, "OK"]
897 end
898
899 if env['REQUEST_URI'] != '/'
900 puts 'BADREQUEST1: non-/ request "' +
901 env['REQUEST_URI'] + '", method "' +
902 env['REQUEST_METHOD'] + '"'
903 return [200, {}, "OK"]
904 end
905
906 if env['REQUEST_METHOD'] != 'POST'
907 puts 'BADREQUEST2: non-POST request; URI: "' +
908 env['REQUEST_URI'] + '", method "' +
909 env['REQUEST_METHOD'] + '"'
910 return [200, {}, "OK"]
911 end
912
913 # TODO: process each message in list, not just first one
914 jparams = params.dig('_json', 0, 'message')
915 type = params.dig('_json', 0, 'type')
916
917 return [400, {}, "Missing params\n"] unless jparams && type
918
919 users_num, others_num = if jparams['direction'] == 'in'
920 [jparams['owner'], jparams['from']]
921 elsif jparams['direction'] == 'out'
922 [jparams['from'], jparams['owner']]
923 else
924 puts "big prob: '#{jparams['direction']}'"
925 return [400, {}, "OK"]
926 end
927
928 jparams['to'].reject! { |num|
929 num == users_num || num == others_num
930 }
931
932 return [400, {}, "Missing params\n"] unless users_num && others_num
933 return [400, {}, "Missing params\n"] unless jparams['to'].is_a?(Array)
934
935 puts "BODY - messageId: #{jparams['id']}" \
936 ", eventType: #{type}" \
937 ", time: #{jparams['time']}" \
938 ", direction: #{jparams['direction']}" \
939 ", deliveryState: #{jparams['deliveryState'] || 'NONE'}" \
940 ", errorCode: #{jparams['errorCode'] || 'NONE'}" \
941 ", description: #{jparams['description'] || 'NONE'}" \
942 ", tag: #{jparams['tag'] || 'NONE'}" \
943 ", media: #{jparams['media'] || 'NONE'}"
944
945 if others_num[0] != '+'
946 # TODO: check that others_num actually a shortcode first
947 others_num +=
948 ';phone-context=ca-us.phone-context.soprani.ca'
949 end
950
951 bare_jid = @registration_repo.find_jid(users_num).sync
952
953 if !bare_jid
954 puts "jid_key for (#{users_num}) DNE; BW API misconfigured?"
955
956 return [403, {}, "Customer not found\n"]
957 end
958
959 msg = nil
960 case jparams['direction']
961 when 'in'
962 text = ''
963 case type
964 when 'sms'
965 text = jparams['text']
966 when 'mms'
967 has_media = false
968
969 if jparams['text'].empty?
970 if not has_media
971 text = '[suspected group msg '\
972 'with no text (odd)]'
973 end
974 else
975 text = if has_media
976 # TODO: write/use a caption XEP
977 jparams['text']
978 else
979 '[suspected group msg '\
980 '(recipient list not '\
981 'available) with '\
982 'following text] ' +
983 jparams['text']
984 end
985 end
986
987 # ie. if text param non-empty or had no media
988 if not text.empty?
989 msg = Blather::Stanza::Message.new(
990 bare_jid, text)
991 msg.from = others_num + '@' + ARGV[0]
992 SGXbwmsgsv2.write(msg)
993 end
994
995 return [200, {}, "OK"]
996 when 'message-received'
997 # TODO: handle group chat, and fix above
998 text = jparams['text']
999
1000 if jparams['to'].length > 1
1001 msg = Blather::Stanza::Message.new(
1002 Blather::JID.new(bare_jid).domain,
1003 text
1004 )
1005
1006 addrs = Nokogiri::XML::Node.new(
1007 'addresses', msg.document)
1008 addrs['xmlns'] = 'http://jabber.org/' \
1009 'protocol/address'
1010
1011 addr1 = Nokogiri::XML::Node.new(
1012 'address', msg.document)
1013 addr1['type'] = 'to'
1014 addr1['jid'] = bare_jid
1015 addrs.add_child(addr1)
1016
1017 jparams['to'].each do |receiver|
1018 addrn = Nokogiri::XML::Node.new(
1019 'address', msg.document)
1020 addrn['type'] = 'to'
1021 addrn['uri'] = "sms:#{receiver}"
1022 addrn['delivered'] = 'true'
1023 addrs.add_child(addrn)
1024 end
1025
1026 msg.add_child(addrs)
1027
1028 # TODO: delete
1029 puts "RESPONSE9: #{msg.inspect}"
1030 end
1031
1032 Array(jparams['media']).each do |media_url|
1033 unless media_url.end_with?(
1034 '.smil', '.txt', '.xml'
1035 )
1036 has_media = true
1037 SGXbwmsgsv2.send_media(
1038 others_num + '@' +
1039 ARGV[0],
1040 bare_jid, media_url,
1041 nil, nil, msg
1042 )
1043 end
1044 end
1045 else
1046 text = "unknown type (#{type})"\
1047 " with text: " + jparams['text']
1048
1049 # TODO: log/notify of this properly
1050 puts text
1051 end
1052
1053 if not msg
1054 msg = Blather::Stanza::Message.new(bare_jid, text)
1055 end
1056 else # per prior switch, this is: jparams['direction'] == 'out'
1057 tag_parts = jparams['tag'].split(/ /, 2)
1058 id = WEBrick::HTTPUtils.unescape(tag_parts[0])
1059 resourcepart = WEBrick::HTTPUtils.unescape(tag_parts[1])
1060
1061 # TODO: remove this hack
1062 if jparams['to'].length > 1
1063 puts "WARN! group no rcpt: #{users_num}"
1064 return [200, {}, "OK"]
1065 end
1066
1067 case type
1068 when 'message-failed'
1069 # create a bare message like the one user sent
1070 msg = Blather::Stanza::Message.new(
1071 others_num + '@' + ARGV[0])
1072 msg.from = bare_jid + '/' + resourcepart
1073 msg['id'] = id
1074
1075 # TODO: add 'errorCode' and/or 'description' val
1076 # create an error reply to the bare message
1077 msg = msg.as_error(
1078 'recipient-unavailable',
1079 :wait,
1080 jparams['description']
1081 )
1082
1083 # TODO: make prettier: this should be done above
1084 others_num = params['_json'][0]['to']
1085 when 'message-delivered'
1086
1087 msg = ReceiptMessage.new(bare_jid)
1088
1089 # TODO: put in member/instance variable
1090 msg['id'] = SecureRandom.uuid
1091
1092 # TODO: send only when requested per XEP-0184
1093 rcvd = Nokogiri::XML::Node.new(
1094 'received',
1095 msg.document
1096 )
1097 rcvd['xmlns'] = 'urn:xmpp:receipts'
1098 rcvd['id'] = id
1099 msg.add_child(rcvd)
1100
1101 # TODO: make prettier: this should be done above
1102 others_num = params['_json'][0]['to']
1103 else
1104 # TODO: notify somehow of unknown state receivd?
1105 puts "message with id #{id} has "\
1106 "other type #{type}"
1107 return [200, {}, "OK"]
1108 end
1109
1110 puts "RESPONSE4: #{msg.inspect}"
1111 end
1112
1113 msg.from = others_num + '@' + ARGV[0]
1114 SGXbwmsgsv2.write(msg)
1115
1116 [200, {}, "OK"]
1117 rescue Exception => e
1118 Sentry.capture_exception(e)
1119 puts 'Shutting down gateway due to exception 013: ' + e.message
1120 SGXbwmsgsv2.shutdown
1121 puts 'Gateway has terminated.'
1122 EM.stop
1123 end
1124end
1125
1126at_exit do
1127 $stdout.sync = true
1128
1129 puts "Soprani.ca/SMS Gateway for XMPP - Bandwidth API V2\n"\
1130 "==>> last commit of this version is " + `git rev-parse HEAD` + "\n"
1131
1132 if ARGV.size != 7
1133 puts "Usage: sgx-bwmsgsv2.rb <component_jid> "\
1134 "<component_password> <server_hostname> "\
1135 "<server_port> <application_id> "\
1136 "<http_listen_port> <mms_proxy_prefix_url>"
1137 exit 0
1138 end
1139
1140 t = Time.now
1141 puts "LOG %d.%09d: starting...\n\n" % [t.to_i, t.nsec]
1142
1143 EM.run do
1144 REDIS = EM::Hiredis.connect
1145
1146 SGXbwmsgsv2.run
1147
1148 # required when using Prosody otherwise disconnects on 6-hour inactivity
1149 EM.add_periodic_timer(3600) do
1150 msg = Blather::Stanza::Iq::Ping.new(:get, 'localhost')
1151 msg.from = ARGV[0]
1152 SGXbwmsgsv2.write(msg)
1153 end
1154
1155 server = Goliath::Server.new('0.0.0.0', ARGV[5].to_i)
1156 server.api = WebhookHandler.new
1157 server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
1158 server.logger = Log4r::Logger.new('goliath')
1159 server.logger.add(Log4r::StdoutOutputter.new('console'))
1160 server.logger.level = Log4r::INFO
1161 server.start do
1162 ["INT", "TERM"].each do |sig|
1163 trap(sig) do
1164 EM.defer do
1165 puts 'Shutting down gateway...'
1166 SGXbwmsgsv2.shutdown
1167
1168 puts 'Gateway has terminated.'
1169 EM.stop
1170 end
1171 end
1172 end
1173 end
1174 end
1175end unless ENV['ENV'] == 'test'