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