tel_selections.rb

  1# frozen_string_literal: true
  2
  3require "ruby-bandwidth-iris"
  4Faraday.default_adapter = :em_synchrony
  5
  6require "cbor"
  7require "countries"
  8
  9require_relative "area_code_repo"
 10require_relative "form_template"
 11
 12class TelSelections
 13	THIRTY_DAYS = 60 * 60 * 24 * 30
 14
 15	def initialize(redis: REDIS, db: DB, memcache: MEMCACHE)
 16		@redis = redis
 17		@memcache = memcache
 18		@db = db
 19	end
 20
 21	def set(jid, tel)
 22		@redis.setex("pending_tel_for-#{jid}", THIRTY_DAYS, tel)
 23	end
 24
 25	def delete(jid)
 26		@redis.del("pending_tel_for-#{jid}")
 27	end
 28
 29	def [](jid)
 30		@redis.get("pending_tel_for-#{jid}").then do |tel|
 31			tel ? HaveTel.new(tel) : ChooseTel.new(db: @db, memcache: @memcache)
 32		end
 33	end
 34
 35	class HaveTel
 36		def initialize(tel)
 37			@tel = tel
 38		end
 39
 40		def choose_tel
 41			EMPromise.resolve(@tel)
 42		end
 43	end
 44
 45	class ChooseTel
 46		def initialize(db: DB, memcache: MEMCACHE)
 47			@db = db
 48			@memcache = memcache
 49		end
 50
 51		def choose_tel(error: nil)
 52			Command.reply { |reply|
 53				reply.allowed_actions = [:next]
 54				reply.command << FormTemplate.render("tn_search", error: error)
 55			}.then do |iq|
 56				available = AvailableNumber.for(iq.form, db: @db, memcache: @memcache)
 57				next available if available.is_a?(String)
 58
 59				choose_from_list(available.tns)
 60			rescue StandardError
 61				choose_tel(error: $!.to_s)
 62			end
 63		end
 64
 65		def choose_from_list(tns)
 66			raise "No numbers found, try another search." if tns.empty?
 67
 68			Command.reply { |reply|
 69				reply.allowed_actions = [:next, :prev]
 70				reply.command << FormTemplate.render("tn_list", tns: tns)
 71			}.then { |iq|
 72				tel = iq.form.field("tel")&.value
 73				next choose_tel if iq.prev? || !tel
 74
 75				tel.to_s.strip
 76			}
 77		end
 78
 79		class AvailableNumber
 80			def self.for(form, db: DB, memcache: MEMCACHE)
 81				qs = form.field("q")&.value.to_s.strip
 82				return qs if qs =~ /\A\+1\d{10}\Z/
 83
 84				q = Q.for(feelinglucky(qs, form), db: db, memcache: memcache)
 85
 86				new(
 87					q.iris_query
 88					.merge(enableTNDetail: true, LCA: false)
 89					.merge(Quantity.for(form).iris_query),
 90					q.sql_query,
 91					fallback: q.fallback, memcache: memcache, db: db
 92				)
 93			end
 94
 95			ACTION_FIELD = "http://jabber.org/protocol/commands#actions"
 96
 97			def self.feelinglucky(q, form)
 98				return q unless q.empty?
 99				return q unless form.field(ACTION_FIELD)&.value == "feelinglucky"
100
101				"810"
102			end
103
104			def initialize(
105				iris_query, sql_query, fallback: [], memcache: MEMCACHE, db: DB
106			)
107				@iris_query = iris_query
108				@sql_query = sql_query
109				@fallback = fallback
110				@memcache = memcache
111				@db = db
112			end
113
114			def tns
115				Command.log.debug("BandwidthIris::AvailableNumber.list", @iris_query)
116				unless (result = fetch_cache)
117					result =
118						BandwidthIris::AvailableNumber.list(@iris_query) +
119						fetch_local_inventory.sync
120				end
121				return next_fallback if result.empty? && !@fallback.empty?
122
123				result.map { |tn| Tn.new(**tn) }
124			end
125
126			def fetch_local_inventory
127				@db.query_defer(@sql_query[0], @sql_query[1..-1]).then { |rows|
128					rows.map { |row|
129						{
130							full_number: row["tel"].sub(/\A\+1/, ""),
131							city: row["locality"],
132							state: row["region"]
133						}
134					}
135				}
136			end
137
138			def next_fallback
139				@memcache.set(cache_key, CBOR.encode([]), 43200)
140				fallback = @fallback.shift
141				self.class.new(
142					fallback.iris_query.merge(
143						enableTNDetail: true, quantity: @iris_query[:quantity]
144					),
145					fallback.sql_query,
146					fallback: @fallback,
147					memcache: @memcache, db: @db
148				).tns
149			end
150
151			def fetch_cache
152				promise = EMPromise.new
153				@memcache.get(cache_key, &promise.method(:fulfill))
154				result = promise.sync
155				result ? CBOR.decode(result) : nil
156			end
157
158			def cache_key
159				"BandwidthIris_#{@iris_query.to_a.flatten.join(',')}"
160			end
161
162			class Quantity
163				def self.for(form)
164					if form.field(ACTION_FIELD)&.value == "feelinglucky"
165						return Default.new
166					end
167
168					rsm_max = form.find(
169						"ns:set/ns:max",
170						ns: "http://jabber.org/protocol/rsm"
171					).first
172					return new(rsm_max.content.to_i) if rsm_max
173
174					Default.new
175				end
176
177				def initialize(quantity)
178					@quantity = quantity
179				end
180
181				def iris_query
182					{ quantity: @quantity }
183				end
184
185				# NOTE: Gajim sends back the whole list on submit, so big
186				# lists can cause issues
187				class Default
188					def iris_query
189						{ quantity: 10 }
190					end
191				end
192			end
193		end
194
195		class Tn
196			attr_reader :tel
197
198			def initialize(full_number:, city:, state:, **)
199				@tel = "+1#{full_number}"
200				@locality = city
201				@region = state
202			end
203
204			def formatted_tel
205				@tel =~ /\A\+1(\d{3})(\d{3})(\d+)\Z/
206				"(#{$1}) #{$2}-#{$3}"
207			end
208
209			def option
210				op = Blather::Stanza::X::Field::Option.new(value: tel, label: to_s)
211				op << reference
212				op
213			end
214
215			def reference
216				Nokogiri::XML::Builder.new { |xml|
217					xml.reference(
218						xmlns: "urn:xmpp:reference:0",
219						begin: 0,
220						end: formatted_tel.length - 1,
221						type: "data",
222						uri: "tel:#{tel}"
223					)
224				}.doc.root
225			end
226
227			def to_s
228				"#{formatted_tel} (#{@locality}, #{@region})"
229			end
230		end
231
232		class Q
233			def self.register(regex, &block)
234				@queries ||= []
235				@queries << [regex, block]
236			end
237
238			def self.for(q, **kwa)
239				q = replace_region_names(q) unless q.start_with?("~")
240
241				@queries.each do |(regex, block)|
242					match_data = (q =~ regex)
243					return block.call($1 || $&, *$~.to_a[2..-1], **kwa) if match_data
244				end
245
246				raise "Format not recognized: #{q}"
247			end
248
249			def self.replace_region_names(query)
250				ISO3166::Country[:US].subdivisions.merge(
251					ISO3166::Country[:CA].subdivisions
252				).reduce(query) do |q, (code, region)|
253					([region.name] + Array(region.unofficial_names))
254						.reduce(q) do |r, name|
255							r.sub(/#{name}\s*(?!,)/i, code)
256						end
257				end
258			end
259
260			def initialize(q, **)
261				@q = q
262			end
263
264			def fallback
265				[]
266			end
267
268			{
269				areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
270				npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
271				npaNxxx: [:NpaNxxx, /\A(?:[2-9][0-9]{2}){2}[0-9]\Z/]
272			}.each do |k, args|
273				klass = const_set(
274					args[0],
275					Class.new(Q) {
276						define_method(:iris_query) do
277							{ k => @q }
278						end
279
280						define_method(:sql_query) do
281							["SELECT * FROM tel_inventory WHERE tel LIKE ?", "+1#{@q}%"]
282						end
283					}
284				)
285
286				args[1..-1].each do |regex|
287					register(regex) { |q, **| klass.new(q) }
288				end
289			end
290
291			class PostalCode < Q
292				Q.register(/\A\d{5}(?:-\d{4})?\Z/, &method(:new))
293
294				def iris_query
295					{ zip: @q }
296				end
297
298				def sql_query
299					nil
300				end
301			end
302
303			class LocalVanity < Q
304				Q.register(/\A~(.+)\Z/, &method(:new))
305
306				def iris_query
307					{ localVanity: @q }
308				end
309
310				def sql_query
311					["SELECT * FROM tel_inventory WHERE tel LIKE ?", "%#{q_digits}%"]
312				end
313
314				def q_digits
315					@q
316						.gsub(/[ABC]/i, "2")
317						.gsub(/[DEF]/i, "3")
318						.gsub(/[GHI]/i, "4")
319						.gsub(/[JKL]/i, "5")
320						.gsub(/[MNO]/i, "6")
321						.gsub(/[PQRS]/i, "7")
322						.gsub(/[TUV]/i, "8")
323						.gsub(/[WXYZ]/i, "9")
324				end
325			end
326
327			class State
328				Q.register(/\A[a-zA-Z]{2}\Z/, &method(:new))
329
330				STATE_MAP = {
331					"QC" => "PQ"
332				}.freeze
333
334				def initialize(state, **)
335					@state = STATE_MAP.fetch(state.upcase, state.upcase)
336				end
337
338				def fallback
339					[]
340				end
341
342				def iris_query
343					{ state: @state }
344				end
345
346				def sql_query
347					["SELECT * FROM tel_inventory WHERE region = ?", @state]
348				end
349
350				def to_s
351					@state
352				end
353			end
354
355			class CityState
356				Q.register(/\A([^,]+)\s*,\s*([a-zA-Z]{2})\Z/, &method(:new))
357
358				CITY_MAP = {
359					"ajax" => "Ajax-Pickering",
360					"kitchener" => "Kitchener-Waterloo",
361					"new york" => "New York City",
362					"pickering" => "Ajax-Pickering",
363					"sault ste marie" => "sault sainte marie",
364					"sault ste. marie" => "sault sainte marie",
365					"south durham" => "Durham",
366					"township of langley" => "Langley",
367					"waterloo" => "Kitchener-Waterloo",
368					"west durham" => "Durham"
369				}.freeze
370
371				def initialize(city, state, db: DB, memcache: MEMCACHE)
372					@city = CITY_MAP.fetch(city.downcase, city)
373					@state = State.new(state)
374					@db = db
375					@memcache = memcache
376				end
377
378				def fallback
379					LazyObject.new do
380						AreaCodeRepo.new(
381							db: @db,
382							geo_code_repo: GeoCodeRepo.new(memcache: @memcache)
383						).find(to_s).sync.map { |area_code|
384							AreaCode.new(area_code)
385						}
386					end
387				end
388
389				def iris_query
390					@state.iris_query.merge(city: @city)
391				end
392
393				def sql_query
394					[
395						"SELECT * FROM tel_inventory WHERE region = ? AND locality = ?",
396						@state.to_s, @city
397					]
398				end
399
400				def to_s
401					"#{@city}, #{@state}"
402				end
403			end
404		end
405	end
406end