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