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