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