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