1#!/usr/bin/ruby
2# frozen_string_literal: true
3
4require "blather/client/dsl"
5require "dhall"
6require "em-hiredis"
7require "em-http"
8require "em_promise"
9require "json"
10require "ougai"
11require "sentry-ruby"
12
13require_relative "lib/addresses"
14require_relative "lib/blather_client"
15require_relative "lib/blather_ext"
16require_relative "lib/em"
17require_relative "lib/incoming_mms"
18require_relative "lib/oob"
19require_relative "lib/outgoing_mms"
20require_relative "lib/proxied_jid"
21require_relative "lib/registration_repo"
22
23singleton_class.class_eval do
24 include Blather::DSL
25
26 Blather::DSL.append_features(self)
27end
28
29$stdout.sync = true
30LOG = Ougai::Logger.new($stdout)
31LOG.level = ENV.fetch("LOG_LEVEL", "info")
32LOG.formatter = Ougai::Formatters::Readable.new(
33 nil,
34 nil,
35 plain: !$stdout.isatty
36)
37Blather.logger = LOG
38
39def log
40 Thread.current[:log] || LOG
41end
42
43Sentry.init do |config|
44 config.logger = LOG
45 config.breadcrumbs_logger = [:sentry_logger]
46end
47
48CONFIG = Dhall::Coder
49 .new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
50 .load(
51 "(#{ARGV[0]}) : #{__dir__}/config-schema.dhall",
52 transform_keys: ->(k) { k&.to_sym }
53 )
54
55def panic(e, hub=nil)
56 log.fatal(
57 "Error raised during event loop: #{e.class}",
58 e
59 )
60 if e.is_a?(Exception)
61 (hub || Sentry).capture_exception(e, hint: { background: false })
62 else
63 (hub || Sentry).capture_message(e.to_s, hint: { background: false })
64 end
65 exit 1
66end
67
68EM.error_handler(&method(:panic))
69
70@client = BlatherClient.new
71
72setup(
73 CONFIG[:component][:jid],
74 CONFIG[:component][:secret],
75 CONFIG[:server][:host],
76 CONFIG[:server][:port],
77 nil,
78 nil,
79 async: true
80)
81
82@connected = false
83@shutdown_requested = false
84
85when_ready do
86 @connected = true
87 log.info "Ready"
88 REDIS = EM::Hiredis.connect
89 REGISTRATION_REPO = RegistrationRepo.new(redis: REDIS)
90end
91
92disconnected do
93 next if @shutdown_requested
94
95 if @connected
96 log.fatal(
97 "XMPP connection lost",
98 server: "#{CONFIG[:server][:host]}:#{CONFIG[:server][:port]}",
99 component: CONFIG[:component][:jid]
100 )
101 else
102 log.fatal(
103 "Failed to establish XMPP connection",
104 server: "#{CONFIG[:server][:host]}:#{CONFIG[:server][:port]}",
105 component: CONFIG[:component][:jid]
106 )
107 end
108end
109
110disco_info to: Blather::JID.new(CONFIG[:component][:jid]) do |iq|
111 reply = iq.reply
112 reply.identities = [{
113 name: "SGX Endstream",
114 type: "sms",
115 category: "gateway"
116 }]
117 reply.features = [
118 "http://jabber.org/protocol/disco#info",
119 "http://jabber.org/protocol/address",
120 "jabber:iq:register"
121 ]
122 self << reply
123end
124
125disco_info to: /\A\+?\d+@/ do |iq|
126 reply = iq.reply
127 reply.identities = [{
128 name: "SGX Endstream",
129 type: "sms",
130 category: "client"
131 }]
132 reply.features = [
133 "urn:xmpp:receipts"
134 ]
135 self << reply
136end
137
138ibr type: :get do |iq|
139 REGISTRATION_REPO.find(iq.from).then do |creds|
140 reply = iq.reply
141 reply.registered = true if creds&.first
142 reply.phone = creds&.first.to_s
143 reply.username = ""
144 reply.password = ""
145 self << reply
146 end
147end
148
149ibr type: :set do |iq|
150 unless iq.remove?
151 if iq.phone.to_s !~ /\A\+?1\d{10}\Z/
152 self << iq.as_error("bad-request", :modify, "Invalid phone number")
153 next
154 end
155
156 if iq.username.to_s.empty? || iq.password.to_s.empty?
157 self << iq.as_error(
158 "bad-request",
159 :modify,
160 "Username and password are required"
161 )
162 next
163 end
164 end
165
166 REGISTRATION_REPO.delete(iq.from).then { |status|
167 next status if !status[:code].to_i.zero? || iq.remove?
168
169 REGISTRATION_REPO.put(iq.from, iq.phone, iq.username, iq.password)
170 }.then { |status|
171 self << if status[:code].to_i.zero?
172 iq.reply
173 else
174 iq.as_error("bad-request", :modify, status[:text])
175 end
176 }
177end
178
179message from: /@sms.chat.1pcom.net\Z/ do |m|
180 json = JSON.parse(m.body) rescue nil
181
182 tel = if m.from.node.length > 7
183 "+#{m.from.node}"
184 else
185 "#{m.from.node};phone-context=ca-us.phone-context.soprani.ca"
186 end
187 m = m.dup
188 m.from = Blather::JID.new(tel, CONFIG[:component][:jid])
189 m.to = ProxiedJID.new(m.to).unproxied
190 m.subject = nil # They send a generic subject for some reason
191
192 if json.is_a?(Hash) && json["response"]
193 log.info("SMS Status", json)
194 resp = json["response"]
195 m.id = resp["id"]
196 swap = m.from
197 m.from = m.to
198 m.to = swap
199 m.body = ""
200 m = m.as_error(
201 "recipient-unavailable",
202 :cancel,
203 "#{resp['text']} (#{resp['code']} #{resp['subcode']} #{resp['dlrid']})"
204 )
205 end
206
207 self << m
208end
209
210message from: /@mms.chat.1pcom.net\Z/ do |m|
211 json = JSON.parse(m.body)
212 if json.is_a?(Hash) && json["GlobalStatus"]
213 log.info("MMS Status", json["disposition"])
214 next
215 end
216
217 IncomingMMS.for(m.to, json).then(&:to_stanza).then { |to_send|
218 to_send.id = m.id
219 to_send.from = Blather::JID.new("+#{m.from.node}", CONFIG[:component][:jid])
220 self << to_send
221 }
222end
223
224# Swallow errors, endstream doesn't want them
225message type: :error do
226 true
227end
228
229message :addresses, to: Blather::JID.new(CONFIG[:component][:jid]) do |m|
230 self << OutgoingMMS.for(m).to_stanza(id: m.id, from: m.from)
231end
232
233message ->(m) { !m.oobs.empty? }, to: /\A\+?\d+@/ do |m|
234 # TODO: if too big or bad mime, send sms
235 self << OutgoingMMS.for(m).to_stanza(id: m.id, from: m.from)
236end
237
238def too_long_for_sms?(m)
239 # ~3 segments
240 m.body.length > (m.body.ascii_only? ? 160 : 70) * 3
241end
242
243message :body, method(:too_long_for_sms?).to_proc, to: /\A\+?\d+@/ do |m|
244 self << OutgoingMMS.for(m).to_stanza(id: m.id, from: m.from)
245end
246
247message(
248 :body,
249 to: /(?:\A\+?\d+@)|(?:;phone-context=ca-us\.phone-context\.soprani\.ca@)/
250) do |m|
251 m.to = Blather::JID.new(
252 m.to.node.sub(/\A\+/, "").sub(/;phone-context=.*\Z/, ""),
253 "sms.chat.1pcom.net"
254 )
255 m.from = ProxiedJID.proxy(m.from, CONFIG[:component][:jid])
256 self << m
257end
258
259iq type: [:get, :set] do |iq|
260 self << iq.as_error("service-unavailable", :cancel)
261end
262
263trap(:INT) { @shutdown_requested = true; EM.stop }
264trap(:TERM) { @shutdown_requested = true; EM.stop }
265EM.run { client.run }