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_relative '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 && context == 'phone-context=ca-us.phone-context.soprani.ca'
47end
48
49def is_anonymous_tel?(dest)
50 num, context = dest.split(';', 2)
51 context && context == 'phone-context=anonymous.phone-context.soprani.ca'
52end
53
54class SGXClient < Blather::Client
55 def register_handler(type, *guards, &block)
56 super(type, *guards) { |*args| wrap_handler(*args, &block) }
57 end
58
59 def register_handler_before(type, *guards, &block)
60 check_handler(type, guards)
61 handler = lambda { |*args| wrap_handler(*args, &block) }
62
63 @handlers[type] ||= []
64 @handlers[type].unshift([guards, handler])
65 end
66
67protected
68
69 def wrap_handler(*args, &block)
70 v = block.call(*args)
71 v.catch(&method(:panic)) if v.is_a?(Promise)
72 true # Do not run other handlers unless throw :pass
73 rescue Exception => e
74 panic(e)
75 end
76end
77
78# TODO: keep in sync with jmp-acct_bot.rb, and eventually put in common location
79module CatapultSettingFlagBits
80 VOICEMAIL_TRANSCRIPTION_DISABLED = 0
81 MMS_ON_OOB_URL = 1
82end
83
84module SGXbwmsgsv2
85 extend Blather::DSL
86
87 @client = SGXClient.new
88 @gateway_features = [
89 "http://jabber.org/protocol/disco#info",
90 "jabber:iq:register"
91 ]
92
93 def self.run
94 client.run
95 end
96
97 # so classes outside this module can write messages, too
98 def self.write(stanza)
99 client.write(stanza)
100 end
101
102 def self.before_handler(type, *guards, &block)
103 client.register_handler_before(type, *guards, &block)
104 end
105
106 def self.send_media(from, to, media_url, desc=nil, subject=nil)
107 # we assume media_url is of the form (always the case so far):
108 # https://api.catapult.inetwork.com/v1/users/[uid]/media/[file]
109
110 # the caller must guarantee that 'to' is a bare JID
111 proxy_url = ARGV[6] + WEBrick::HTTPUtils.escape(to) + '/' +
112 media_url.split('/', 8)[7]
113
114 puts 'ORIG_URL: ' + media_url
115 puts 'PROX_URL: ' + proxy_url
116
117 # put URL in the body (so Conversations will still see it)...
118 msg = Blather::Stanza::Message.new(to, proxy_url)
119 msg.from = from
120 msg.subject = subject if subject
121
122 # ...but also provide URL in XEP-0066 (OOB) fashion
123 # TODO: confirm client supports OOB or don't send this
124 x = Nokogiri::XML::Node.new 'x', msg.document
125 x['xmlns'] = 'jabber:x:oob'
126
127 urln = Nokogiri::XML::Node.new 'url', msg.document
128 urlc = Nokogiri::XML::Text.new proxy_url, msg.document
129 urln.add_child(urlc)
130 x.add_child(urln)
131
132 if desc
133 descn = Nokogiri::XML::Node.new('desc', msg.document)
134 descc = Nokogiri::XML::Text.new(desc, msg.document)
135 descn.add_child(descc)
136 x.add_child(descn)
137 end
138
139 msg.add_child(x)
140
141 write(msg)
142 rescue Exception => e
143 panic(e)
144 end
145
146 def self.error_msg(orig, query_node, type, name, text=nil)
147 orig.type = :error
148
149 error = Nokogiri::XML::Node.new 'error', orig.document
150 error['type'] = type
151 orig.add_child(error)
152
153 suberr = Nokogiri::XML::Node.new name, orig.document
154 suberr['xmlns'] = 'urn:ietf:params:xml:ns:xmpp-stanzas'
155 error.add_child(suberr)
156
157 orig.add_child(query_node) if query_node
158
159 # TODO: add some explanatory xml:lang='en' text (see text param)
160 puts "RESPONSE3: #{orig.inspect}"
161 return orig
162 end
163
164 # workqueue_count MUST be 0 or else Blather uses threads!
165 setup ARGV[0], ARGV[1], ARGV[2], ARGV[3], nil, nil, workqueue_count: 0
166
167 def self.pass_on_message(m, users_num, jid)
168 # setup delivery receipt; similar to a reply
169 rcpt = ReceiptMessage.new(m.from.stripped)
170 rcpt.from = m.to
171
172 # pass original message (before sending receipt)
173 m.to = jid
174 m.from = "#{users_num}@#{ARGV[0]}"
175
176 puts 'XRESPONSE0: ' + m.inspect
177 write_to_stream m
178
179 # send a delivery receipt back to the sender
180 # TODO: send only when requested per XEP-0184
181 # TODO: pass receipts from target if supported
182
183 # TODO: put in member/instance variable
184 rcpt['id'] = SecureRandom.uuid
185 rcvd = Nokogiri::XML::Node.new 'received', rcpt.document
186 rcvd['xmlns'] = 'urn:xmpp:receipts'
187 rcvd['id'] = m.id
188 rcpt.add_child(rcvd)
189
190 puts 'XRESPONSE1: ' + rcpt.inspect
191 write_to_stream rcpt
192 end
193
194 def self.call_catapult(
195 token, secret, m, pth, body=nil,
196 head={}, code=[200], respond_with=:body
197 )
198 # TODO: need to make a separate thing for voice.bw.c eventually
199 EM::HttpRequest.new(
200 "https://messaging.bandwidth.com/#{pth}"
201 ).public_send(
202 m,
203 head: {
204 'Authorization' => [token, secret]
205 }.merge(head),
206 body: body
207 ).then { |http|
208 puts "API response to send: #{http.response} with code"\
209 " response.code #{http.response_header.status}"
210
211 if code.include?(http.response_header.status)
212 case respond_with
213 when :body
214 http.response
215 when :headers
216 http.response_header
217 else
218 http
219 end
220 else
221 EMPromise.reject(http.response_header.status)
222 end
223 }
224 end
225
226 def self.to_catapult_possible_oob(s, num_dest, user_id, token, secret,
227 usern)
228
229 xn = s.children.find { |v| v.element_name == "x" }
230 if not xn
231 to_catapult(s, nil, num_dest, user_id, token, secret,
232 usern)
233 return
234 end
235 puts "MMSOOB: found an x node - checking for url node..."
236
237 # TODO: also check for xmlns='jabber:x:oob' in <x/> - the below
238 # is probably fine, though, as non-OOB <x><url/></x> unlikely
239
240 un = xn.children.find { |v| v.element_name == "url" }
241 if not un
242 puts "MMSOOB: no url node found so process as normal"
243 to_catapult(s, nil, num_dest, user_id, token, secret,
244 usern)
245 return
246 end
247 puts "MMSOOB: found a url node - checking if to make MMS..."
248
249 REDIS.getbit("catapult_setting_flags-#{s.from.stripped}",
250 CatapultSettingFlagBits::MMS_ON_OOB_URL).then { |oob_on|
251
252 puts "MMSOOB: found MMS_ON_OOB_URL value is '#{oob_on}'"
253 if 0 == oob_on
254 puts "MMSOOB: MMS_ON_OOB_URL off so no MMS send"
255 to_catapult(s, nil, num_dest, user_id, token,
256 secret, usern)
257 next
258 end
259
260 # TODO: check size of file at un.text and shrink if need
261
262 body = s.respond_to?(:body) ? s.body : ''
263 puts "MMSOOB: url text is '#{un.text}'"
264 puts "MMSOOB: the body is '#{body.to_s.strip}'"
265
266 # some clients send URI in both body & <url/> so delete
267 if un.text == body.to_s.strip
268 puts "MMSOOB: url matches body so deleting body"
269 s.body = ''
270 end
271
272 puts "MMSOOB: sending MMS since found OOB & user asked"
273 to_catapult(s, un.text, num_dest, user_id, token,
274 secret, usern)
275 }
276 end
277
278 def self.to_catapult(s, murl, num_dest, user_id, token, secret, usern)
279 body = s.respond_to?(:body) ? s.body : ''
280 if murl.to_s.empty? && body.to_s.strip.empty?
281 return EMPromise.reject(
282 [:modify, 'policy-violation']
283 )
284 end
285
286 extra = {}
287 if murl
288 extra = { media: murl }
289 end
290
291 call_catapult(
292 token,
293 secret,
294 :post,
295 "api/v2/users/#{user_id}/messages",
296 JSON.dump(extra.merge(
297 from: usern,
298 to: num_dest,
299 text: body,
300 applicationId: ARGV[4],
301 tag:
302 # callbacks need id and resourcepart
303 WEBrick::HTTPUtils.escape(s.id.to_s) +
304 ' ' +
305 WEBrick::HTTPUtils.escape(
306 s.from.resource.to_s
307 )
308 )),
309 {'Content-Type' => 'application/json'},
310 [201]
311 ).catch {
312 # TODO: add text; mention code number
313 EMPromise.reject(
314 [:cancel, 'internal-server-error']
315 )
316 }
317
318 t = Time.now
319 tai_timestamp = `./tai`.strip
320 tai_yyyymmdd = Time.at(tai_timestamp.to_i).strftime('%Y%m%d')
321 puts "SMU %d.%09d, %s: msg for %s sent on %s - incrementing\n" %
322 [t.to_i, t.nsec, tai_timestamp, usern, tai_yyyymmdd]
323
324 REDIS.incr('usage_messages-' + tai_yyyymmdd + '-' +
325 usern).then { |total|
326
327 t = Time.now
328 puts "SMU %d.%09d: total msgs for %s-%s now at %s\n" %
329 [t.to_i, t.nsec, tai_yyyymmdd, usern, total]
330 }
331 end
332
333 def self.validate_num(num)
334 EMPromise.resolve(num.to_s).then { |num_dest|
335 if num_dest =~ /\A\+?[0-9]+(?:;.*)?\Z/
336 next num_dest if num_dest[0] == '+'
337 shortcode = extract_shortcode(num_dest)
338 next shortcode if shortcode
339 end
340
341 if is_anonymous_tel?(num_dest)
342 EMPromise.reject([:cancel, 'gone'])
343 else
344 numbers = num_dest.split(',')
345 if numbers.length > 1
346 # TODO: validate each element of numbers
347 next numbers
348 end
349
350 # TODO: text re num not (yet) supportd/implmentd
351 EMPromise.reject([:cancel, 'item-not-found'])
352 end
353 }
354 end
355
356 def self.fetch_catapult_cred_for(jid)
357 cred_key = "catapult_cred-#{jid.stripped}"
358 REDIS.lrange(cred_key, 0, 3).then { |creds|
359 if creds.length < 4
360 # TODO: add text re credentials not registered
361 EMPromise.reject(
362 [:auth, 'registration-required']
363 )
364 else
365 creds
366 end
367 }
368 end
369
370 message :error? do |m|
371 # TODO: report it somewhere/somehow - eat for now so no err loop
372 puts "EATERROR1: #{m.inspect}"
373 end
374
375 message :body do |m|
376 EMPromise.all([
377 validate_num(m.to.node),
378 fetch_catapult_cred_for(m.from)
379 ]).then { |(num_dest, creds)|
380 jid_key = "catapult_jid-#{num_dest}"
381 REDIS.get(jid_key).then { |jid|
382 [jid, num_dest] + creds
383 }
384 }.then { |(jid, num_dest, *creds)|
385 # if destination user is in the system pass on directly
386 if jid
387 pass_on_message(m, creds.last, jid)
388 else
389 to_catapult_possible_oob(m, num_dest, *creds)
390 end
391 }.catch { |e|
392 if e.is_a?(Array) && e.length == 2
393 write_to_stream error_msg(m.reply, m.body, *e)
394 else
395 EMPromise.reject(e)
396 end
397 }
398 end
399
400 def self.user_cap_identities
401 [{category: 'client', type: 'sms'}]
402 end
403
404 def self.user_cap_features
405 [
406 "urn:xmpp:receipts"
407 ]
408 end
409
410 def self.add_gateway_feature(feature)
411 @gateway_features << feature
412 @gateway_features.uniq!
413 end
414
415 subscription :request? do |p|
416 puts "PRESENCE1: #{p.inspect}"
417
418 # subscriptions are allowed from anyone - send reply immediately
419 msg = Blather::Stanza::Presence.new
420 msg.to = p.from
421 msg.from = p.to
422 msg.type = :subscribed
423
424 puts 'RESPONSE5a: ' + msg.inspect
425 write_to_stream msg
426
427 # send a <presence> immediately; not automatically probed for it
428 # TODO: refactor so no "presence :probe? do |p|" duplicate below
429 caps = Blather::Stanza::Capabilities.new
430 # TODO: user a better node URI (?)
431 caps.node = 'http://catapult.sgx.soprani.ca/'
432 caps.identities = user_cap_identities
433 caps.features = user_cap_features
434
435 msg = caps.c
436 msg.to = p.from
437 msg.from = p.to.to_s + '/sgx'
438
439 puts 'RESPONSE5b: ' + msg.inspect
440 write_to_stream msg
441
442 # need to subscribe back so Conversations displays images inline
443 msg = Blather::Stanza::Presence.new
444 msg.to = p.from.to_s.split('/', 2)[0]
445 msg.from = p.to.to_s.split('/', 2)[0]
446 msg.type = :subscribe
447
448 puts 'RESPONSE5c: ' + msg.inspect
449 write_to_stream msg
450 end
451
452 presence :probe? do |p|
453 puts 'PRESENCE2: ' + p.inspect
454
455 caps = Blather::Stanza::Capabilities.new
456 # TODO: user a better node URI (?)
457 caps.node = 'http://catapult.sgx.soprani.ca/'
458 caps.identities = user_cap_identities
459 caps.features = user_cap_features
460
461 msg = caps.c
462 msg.to = p.from
463 msg.from = p.to.to_s + '/sgx'
464
465 puts 'RESPONSE6: ' + msg.inspect
466 write_to_stream msg
467 end
468
469 iq '/iq/ns:query', ns: 'http://jabber.org/protocol/disco#info' do |i|
470 # respond to capabilities request for an sgx-bwmsgsv2 number JID
471 if i.to.node
472 # TODO: confirm the node URL is expected using below
473 #puts "XR[node]: #{xpath_result[0]['node']}"
474
475 msg = i.reply
476 msg.node = i.node
477 msg.identities = user_cap_identities
478 msg.features = user_cap_features
479
480 puts 'RESPONSE7: ' + msg.inspect
481 write_to_stream msg
482 next
483 end
484
485 # respond to capabilities request for sgx-bwmsgsv2 itself
486 msg = i.reply
487 msg.node = i.node
488 msg.identities = [{
489 name: 'Soprani.ca Gateway to XMPP - Bandwidth API V2',
490 type: 'sms', category: 'gateway'
491 }]
492 msg.features = @gateway_features
493 write_to_stream msg
494 end
495
496 def self.check_then_register(i, *creds)
497 jid_key = "catapult_jid-#{creds.last}"
498 bare_jid = i.from.stripped
499 cred_key = "catapult_cred-#{bare_jid}"
500
501 REDIS.get(jid_key).then { |existing_jid|
502 if existing_jid && existing_jid != bare_jid
503 # TODO: add/log text: credentials exist already
504 EMPromise.reject([:cancel, 'conflict'])
505 end
506 }.then {
507 REDIS.lrange(cred_key, 0, 3)
508 }.then { |existing_creds|
509 # TODO: add/log text: credentials exist already
510 if existing_creds.length == 4 && creds != existing_creds
511 EMPromise.reject([:cancel, 'conflict'])
512 elsif existing_creds.length < 4
513 REDIS.rpush(cred_key, *creds).then { |length|
514 if length != 4
515 EMPromise.reject([
516 :cancel,
517 'internal-server-error'
518 ])
519 end
520 }
521 end
522 }.then {
523 # not necessary if existing_jid non-nil, easier this way
524 REDIS.set(jid_key, bare_jid)
525 }.then { |result|
526 if result != 'OK'
527 # TODO: add txt re push failure
528 EMPromise.reject(
529 [:cancel, 'internal-server-error']
530 )
531 end
532 }.then {
533 write_to_stream i.reply
534 }
535 end
536
537 def self.creds_from_registration_query(qn)
538 xn = qn.children.find { |v| v.element_name == "x" }
539
540 if xn
541 xn.children.each_with_object({}) do |field, h|
542 next if field.element_name != "field"
543 val = field.children.find { |v|
544 v.element_name == "value"
545 }
546
547 case field['var']
548 when 'nick'
549 h[:user_id] = val.text
550 when 'username'
551 h[:api_token] = val.text
552 when 'password'
553 h[:api_secret] = val.text
554 when 'phone'
555 h[:phone_num] = val.text
556 else
557 # TODO: error
558 puts "?: #{field['var']}"
559 end
560 end
561 else
562 qn.children.each_with_object({}) do |field, h|
563 case field.element_name
564 when "nick"
565 h[:user_id] = field.text
566 when "username"
567 h[:api_token] = field.text
568 when "password"
569 h[:api_secret] = field.text
570 when "phone"
571 h[:phone_num] = field.text
572 end
573 end
574 end.values_at(:user_id, :api_token, :api_secret, :phone_num)
575 end
576
577 def self.process_registration(i, qn)
578 EMPromise.resolve(
579 qn.children.find { |v| v.element_name == "remove" }
580 ).then { |rn|
581 if rn
582 puts "received <remove/> - ignoring for now..."
583 EMPromise.reject(:done)
584 else
585 creds_from_registration_query(qn)
586 end
587 }.then { |user_id, api_token, api_secret, phone_num|
588 if phone_num[0] == '+'
589 [user_id, api_token, api_secret, phone_num]
590 else
591 # TODO: add text re number not (yet) supported
592 EMPromise.reject([:cancel, 'item-not-found'])
593 end
594 }.then { |user_id, api_token, api_secret, phone_num|
595 # TODO: find way to verify #{phone_num}, too
596 call_catapult(
597 api_token,
598 api_secret,
599 :get,
600 "api/v2/users/#{user_id}/media"
601 ).then { |response|
602 params = JSON.parse(response)
603 # TODO: confirm params is array - could be empty
604
605 puts "register got str #{response.to_s[0..999]}"
606
607 check_then_register(
608 i,
609 user_id,
610 api_token,
611 api_secret,
612 phone_num
613 )
614 }
615 }.catch { |e|
616 EMPromise.reject(case e
617 when 401
618 # TODO: add text re bad credentials
619 [:auth, 'not-authorized']
620 when 404
621 # TODO: add text re number not found or disabled
622 [:cancel, 'item-not-found']
623 when Integer
624 [:modify, 'not-acceptable']
625 else
626 e
627 end)
628 }
629 end
630
631 def self.registration_form(orig, existing_number=nil)
632 msg = Nokogiri::XML::Node.new 'query', orig.document
633 msg['xmlns'] = 'jabber:iq:register'
634
635 if existing_number
636 msg.add_child(
637 Nokogiri::XML::Node.new(
638 'registered', msg.document
639 )
640 )
641 end
642
643 # TODO: update "User Id" x2 below (to "accountId"?), and others?
644 n1 = Nokogiri::XML::Node.new(
645 'instructions', msg.document
646 )
647 n1.content = "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 n2 = Nokogiri::XML::Node.new 'nick', msg.document
657 n3 = Nokogiri::XML::Node.new 'username', msg.document
658 n4 = Nokogiri::XML::Node.new 'password', msg.document
659 n5 = Nokogiri::XML::Node.new 'phone', msg.document
660 n5.content = existing_number.to_s
661 msg.add_child(n1)
662 msg.add_child(n2)
663 msg.add_child(n3)
664 msg.add_child(n4)
665 msg.add_child(n5)
666
667 x = Blather::Stanza::X.new :form, [
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 x.title = 'Register for '\
687 'Soprani.ca Gateway to XMPP - Bandwidth API V2'
688 x.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 msg.add_child(x)
696
697 orig.add_child(msg)
698
699 return orig
700 end
701
702 iq '/iq/ns:query', ns: 'jabber:iq:register' do |i, qn|
703 puts "IQ: #{i.inspect}"
704
705 case i.type
706 when :set
707 process_registration(i, qn)
708 when :get
709 bare_jid = i.from.stripped
710 cred_key = "catapult_cred-#{bare_jid}"
711 REDIS.lindex(cred_key, 3).then { |existing_number|
712 reply = registration_form(i.reply, existing_number)
713 puts "RESPONSE2: #{reply.inspect}"
714 write_to_stream reply
715 }
716 else
717 # Unknown IQ, ignore for now
718 EMPromise.reject(:done)
719 end.catch { |e|
720 if e.is_a?(Array) && e.length == 2
721 write_to_stream error_msg(i.reply, qn, *e)
722 elsif e != :done
723 EMPromise.reject(e)
724 end
725 }.catch(&method(:panic))
726 end
727
728 iq :get? do |i|
729 write_to_stream error_msg(i.reply, i.children, 'cancel', 'feature-not-implemented')
730 end
731
732 iq :set? do |i|
733 write_to_stream error_msg(i.reply, i.children, 'cancel', 'feature-not-implemented')
734 end
735end
736
737class ReceiptMessage < Blather::Stanza
738 def self.new(to=nil)
739 node = super :message
740 node.to = to
741 node
742 end
743end
744
745class WebhookHandler < Goliath::API
746 use Goliath::Rack::Params
747
748 def response(env)
749 puts 'ENV: ' + env.reject{ |k| k == 'params' }.to_s
750
751 users_num = ''
752 others_num = ''
753 if params['direction'] == 'in'
754 users_num = params['to']
755 others_num = params['from']
756 elsif params['direction'] == 'out'
757 users_num = params['from']
758 others_num = params['to']
759 else
760 # TODO: exception or similar
761 puts "big problem: '" + params['direction'] + "'" + body
762 return [200, {}, "OK"]
763 end
764
765 puts 'BODY - messageId: ' + params['messageId'] +
766 ', eventType: ' + params['eventType'] +
767 ', time: ' + params['time'] +
768 ', direction: ' + params['direction'] +
769 ', state: ' + params['state'] +
770 ', deliveryState: ' + (params['deliveryState'] ?
771 params['deliveryState'] : 'NONE') +
772 ', deliveryCode: ' + (params['deliveryCode'] ?
773 params['deliveryCode'] : 'NONE') +
774 ', deliveryDesc: ' + (params['deliveryDescription'] ?
775 params['deliveryDescription'] : 'NONE') +
776 ', tag: ' + (params['tag'] ? params['tag'] : 'NONE') +
777 ', media: ' + (params['media'] ? params['media'].to_s :
778 'NONE')
779
780 if others_num[0] != '+'
781 # TODO: check that others_num actually a shortcode first
782 others_num +=
783 ';phone-context=ca-us.phone-context.soprani.ca'
784 end
785
786 jid_key = "catapult_jid-#{users_num}"
787 bare_jid = REDIS.get(jid_key).promise.sync
788
789 if !bare_jid
790 puts "jid_key (#{jid_key}) DNE; BW API misconfigured?"
791
792 # TODO: likely not appropriate; give error to BW API?
793 # TODO: add text re credentials not being registered
794 #write_to_stream error_msg(m.reply, m.body, :auth,
795 # 'registration-required')
796 return [200, {}, "OK"]
797 end
798
799 msg = ''
800 case params['direction']
801 when 'in'
802 text = ''
803 case params['eventType']
804 when 'sms'
805 text = params['text']
806 when 'mms'
807 has_media = false
808 params['media'].each do |media_url|
809 if not media_url.end_with?(
810 '.smil', '.txt', '.xml'
811 )
812
813 has_media = true
814 SGXbwmsgsv2.send_media(
815 others_num + '@' +
816 ARGV[0],
817 bare_jid, media_url
818 )
819 end
820 end
821
822 if params['text'].empty?
823 if not has_media
824 text = '[suspected group msg '\
825 'with no text (odd)]'
826 end
827 else
828 text = if has_media
829 # TODO: write/use a caption XEP
830 params['text']
831 else
832 '[suspected group msg '\
833 '(recipient list not '\
834 'available) with '\
835 'following text] ' +
836 params['text']
837 end
838 end
839
840 # ie. if text param non-empty or had no media
841 if not text.empty?
842 msg = Blather::Stanza::Message.new(
843 bare_jid, text)
844 msg.from = others_num + '@' + ARGV[0]
845 SGXbwmsgsv2.write(msg)
846 end
847
848 return [200, {}, "OK"]
849 else
850 text = "unknown type (#{params['eventType']})"\
851 " with text: " + params['text']
852
853 # TODO: log/notify of this properly
854 puts text
855 end
856
857 msg = Blather::Stanza::Message.new(bare_jid, text)
858 else # per prior switch, this is: params['direction'] == 'out'
859 tag_parts = params['tag'].split(/ /, 2)
860 id = WEBrick::HTTPUtils.unescape(tag_parts[0])
861 resourcepart = WEBrick::HTTPUtils.unescape(tag_parts[1])
862
863 case params['deliveryState']
864 when 'not-delivered'
865 # create a bare message like the one user sent
866 msg = Blather::Stanza::Message.new(
867 others_num + '@' + ARGV[0])
868 msg.from = bare_jid + '/' + resourcepart
869 msg['id'] = id
870
871 # create an error reply to the bare message
872 msg = Blather::StanzaError.new(
873 msg,
874 'recipient-unavailable',
875 :wait
876 ).to_node
877 when 'delivered'
878 msg = ReceiptMessage.new(bare_jid)
879
880 # TODO: put in member/instance variable
881 msg['id'] = SecureRandom.uuid
882
883 # TODO: send only when requested per XEP-0184
884 rcvd = Nokogiri::XML::Node.new(
885 'received',
886 msg.document
887 )
888 rcvd['xmlns'] = 'urn:xmpp:receipts'
889 rcvd['id'] = id
890 msg.add_child(rcvd)
891 when 'waiting'
892 # can't really do anything with it; nice to know
893 puts "message with id #{id} waiting"
894 return [200, {}, "OK"]
895 else
896 # TODO: notify somehow of unknown state receivd?
897 puts "message with id #{id} has "\
898 "other state #{params['deliveryState']}"
899 return [200, {}, "OK"]
900 end
901
902 puts "RESPONSE4: #{msg.inspect}"
903 end
904
905 msg.from = others_num + '@' + ARGV[0]
906 SGXbwmsgsv2.write(msg)
907
908 [200, {}, "OK"]
909
910 rescue Exception => e
911 puts 'Shutting down gateway due to exception 013: ' + e.message
912 SGXbwmsgsv2.shutdown
913 puts 'Gateway has terminated.'
914 EM.stop
915 end
916end
917
918at_exit do
919 $stdout.sync = true
920
921 puts "Soprani.ca/SMS Gateway for XMPP - Bandwidth API V2\n"\
922 "==>> last commit of this version is " + `git rev-parse HEAD` + "\n"
923
924 if ARGV.size != 7
925 puts "Usage: sgx-bwmsgsv2.rb <component_jid> "\
926 "<component_password> <server_hostname> "\
927 "<server_port> <application_id> "\
928 "<http_listen_port> <mms_proxy_prefix_url>"
929 exit 0
930 end
931
932 t = Time.now
933 puts "LOG %d.%09d: starting...\n\n" % [t.to_i, t.nsec]
934
935 EM.run do
936 REDIS = EM::Hiredis.connect
937
938 SGXbwmsgsv2.run
939
940 # required when using Prosody otherwise disconnects on 6-hour inactivity
941 EM.add_periodic_timer(3600) do
942 msg = Blather::Stanza::Iq::Ping.new(:get, 'localhost')
943 msg.from = ARGV[0]
944 SGXbwmsgsv2.write(msg)
945 end
946
947 server = Goliath::Server.new('0.0.0.0', ARGV[5].to_i)
948 server.api = WebhookHandler.new
949 server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
950 server.logger = Log4r::Logger.new('goliath')
951 server.logger.add(Log4r::StdoutOutputter.new('console'))
952 server.logger.level = Log4r::INFO
953 server.start do
954 ["INT", "TERM"].each do |sig|
955 trap(sig) do
956 EM.defer do
957 puts 'Shutting down gateway...'
958 SGXbwmsgsv2.shutdown
959
960 puts 'Gateway has terminated.'
961 EM.stop
962 end
963 end
964 end
965 end
966 end
967end