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