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