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