sgx_endstream.rb

  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 }