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.pending_value)
 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 = ChooseTel::Tn.for_pending_value(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				choose_from_list_result(tns, iq)
 73			}
 74		end
 75
 76		def choose_from_list_result(tns, iq)
 77			tel = iq.form.field("tel")&.value
 78			return choose_tel if iq.prev? || !tel
 79
 80			tns.find { |tn| tn.tel == tel } || Tn::Bandwidth.new(Tn.new(tel))
 81		end
 82
 83		class AvailableNumber
 84			def self.for(form, db: DB, memcache: MEMCACHE)
 85				qs = form.field("q")&.value.to_s.strip
 86				return qs if qs =~ /\A\+1\d{10}\Z/
 87
 88				q = Q.for(feelinglucky(qs, form), db: db, memcache: memcache)
 89
 90				new(
 91					q.iris_query
 92					.merge(enableTNDetail: true, LCA: false)
 93					.merge(Quantity.for(form).iris_query),
 94					q.sql_query,
 95					fallback: q.fallback, memcache: memcache, db: db
 96				)
 97			end
 98
 99			ACTION_FIELD = "http://jabber.org/protocol/commands#actions"
100
101			def self.feelinglucky(q, form)
102				return q unless q.empty?
103				return q unless form.field(ACTION_FIELD)&.value == "feelinglucky"
104
105				"810"
106			end
107
108			def initialize(
109				iris_query, sql_query, fallback: [], memcache: MEMCACHE, db: DB
110			)
111				@iris_query = iris_query
112				@sql_query = sql_query
113				@fallback = fallback
114				@memcache = memcache
115				@db = db
116			end
117
118			def tns
119				Command.log.debug("BandwidthIris::AvailableNumber.list", @iris_query)
120				unless (result = fetch_cache)
121					result = fetch_bandwidth_inventory + fetch_local_inventory.sync
122				end
123				return next_fallback if result.empty? && !@fallback.empty?
124
125				result
126			end
127
128			def fetch_bandwidth_inventory
129				BandwidthIris::AvailableNumber
130					.list(@iris_query)
131					.map { |tn| Tn::Bandwidth.new(Tn::Option.new(**tn)) }
132			end
133
134			def fetch_local_inventory
135				@db.query_defer(@sql_query[0], @sql_query[1..-1]).then { |rows|
136					rows.map { |row|
137						Tn::LocalInventory.new(Tn::Option.new(
138							full_number: row["tel"].sub(/\A\+1/, ""),
139							city: row["locality"],
140							state: row["region"]
141						))
142					}
143				}
144			end
145
146			def next_fallback
147				@memcache.set(cache_key, CBOR.encode([]), 43200)
148				fallback = @fallback.shift
149				self.class.new(
150					fallback.iris_query.merge(
151						enableTNDetail: true, quantity: @iris_query[:quantity]
152					),
153					fallback.sql_query,
154					fallback: @fallback,
155					memcache: @memcache, db: @db
156				).tns
157			end
158
159			def fetch_cache
160				promise = EMPromise.new
161				@memcache.get(cache_key, &promise.method(:fulfill))
162				result = promise.sync
163				result ? CBOR.decode(result) : nil
164			end
165
166			def cache_key
167				"BandwidthIris_#{@iris_query.to_a.flatten.join(',')}"
168			end
169
170			class Quantity
171				def self.for(form)
172					if form.field(ACTION_FIELD)&.value == "feelinglucky"
173						return Default.new
174					end
175
176					rsm_max = form.find(
177						"ns:set/ns:max",
178						ns: "http://jabber.org/protocol/rsm"
179					).first
180					return new(rsm_max.content.to_i) if rsm_max
181
182					Default.new
183				end
184
185				def initialize(quantity)
186					@quantity = quantity
187				end
188
189				def iris_query
190					{ quantity: @quantity }
191				end
192
193				# NOTE: Gajim sends back the whole list on submit, so big
194				# lists can cause issues
195				class Default
196					def iris_query
197						{ quantity: 10 }
198					end
199				end
200			end
201		end
202
203		class Tn
204			attr_reader :tel
205
206			def self.for_pending_value(value)
207				if value.start_with?("LocalInventory/")
208					LocalInventory.new(Tn.new(value.sub(/\ALocalInventory\//, "")))
209				else
210					Bandwidth.new(Tn.new(value))
211				end
212			end
213
214			def initialize(tel)
215				@tel = tel
216			end
217
218			def formatted_tel
219				@tel =~ /\A\+1(\d{3})(\d{3})(\d+)\Z/
220				"(#{$1}) #{$2}-#{$3}"
221			end
222
223			def to_s
224				formatted_tel
225			end
226
227			class Option < Tn
228				def initialize(full_number:, city:, state:, **)
229					@tel = "+1#{full_number}"
230					@locality = city
231					@region = state
232				end
233
234				def option
235					op = Blather::Stanza::X::Field::Option.new(value: tel, label: to_s)
236					op << reference
237					op
238				end
239
240				def reference
241					Nokogiri::XML::Builder.new { |xml|
242						xml.reference(
243							xmlns: "urn:xmpp:reference:0",
244							begin: 0,
245							end: formatted_tel.length - 1,
246							type: "data",
247							uri: "tel:#{tel}"
248						)
249					}.doc.root
250				end
251
252				def to_s
253					"#{formatted_tel} (#{@locality}, #{@region})"
254				end
255			end
256
257			class Bandwidth < SimpleDelegator
258				def pending_value
259					tel
260				end
261
262				def reserve(customer)
263					BandwidthTnReservationRepo.new.ensure(customer, tel)
264				end
265			end
266
267			class LocalInventory < SimpleDelegator
268				def pending_value
269					"LocalInventory/#{tel}"
270				end
271
272				def reserve(*)
273					EMPromise.resolve(nil)
274				end
275			end
276		end
277
278		class Q
279			def self.register(regex, &block)
280				@queries ||= []
281				@queries << [regex, block]
282			end
283
284			def self.for(q, **kwa)
285				q = replace_region_names(q) unless q.start_with?("~")
286
287				@queries.each do |(regex, block)|
288					match_data = (q =~ regex)
289					return block.call($1 || $&, *$~.to_a[2..-1], **kwa) if match_data
290				end
291
292				raise "Format not recognized: #{q}"
293			end
294
295			def self.replace_region_names(query)
296				ISO3166::Country[:US].subdivisions.merge(
297					ISO3166::Country[:CA].subdivisions
298				).reduce(query) do |q, (code, region)|
299					([region.name] + Array(region.unofficial_names))
300						.reduce(q) do |r, name|
301							r.sub(/#{name}\s*(?!,)/i, code)
302						end
303				end
304			end
305
306			def initialize(q, **)
307				@q = q
308			end
309
310			def fallback
311				[]
312			end
313
314			{
315				areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
316				npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
317				npaNxxx: [:NpaNxxx, /\A(?:[2-9][0-9]{2}){2}[0-9]\Z/]
318			}.each do |k, args|
319				klass = const_set(
320					args[0],
321					Class.new(Q) {
322						define_method(:iris_query) do
323							{ k => @q }
324						end
325
326						define_method(:sql_query) do
327							["SELECT * FROM tel_inventory WHERE tel LIKE ?", "+1#{@q}%"]
328						end
329					}
330				)
331
332				args[1..-1].each do |regex|
333					register(regex) { |q, **| klass.new(q) }
334				end
335			end
336
337			class PostalCode < Q
338				Q.register(/\A\d{5}(?:-\d{4})?\Z/, &method(:new))
339
340				def iris_query
341					{ zip: @q }
342				end
343
344				def sql_query
345					nil
346				end
347			end
348
349			class LocalVanity < Q
350				Q.register(/\A~(.+)\Z/, &method(:new))
351
352				def iris_query
353					{ localVanity: @q }
354				end
355
356				def sql_query
357					["SELECT * FROM tel_inventory WHERE tel LIKE ?", "%#{q_digits}%"]
358				end
359
360				def q_digits
361					@q
362						.gsub(/[ABC]/i, "2")
363						.gsub(/[DEF]/i, "3")
364						.gsub(/[GHI]/i, "4")
365						.gsub(/[JKL]/i, "5")
366						.gsub(/[MNO]/i, "6")
367						.gsub(/[PQRS]/i, "7")
368						.gsub(/[TUV]/i, "8")
369						.gsub(/[WXYZ]/i, "9")
370				end
371			end
372
373			class State
374				Q.register(/\A[a-zA-Z]{2}\Z/, &method(:new))
375
376				STATE_MAP = {
377					"QC" => "PQ"
378				}.freeze
379
380				def initialize(state, **)
381					@state = STATE_MAP.fetch(state.upcase, state.upcase)
382				end
383
384				def fallback
385					[]
386				end
387
388				def iris_query
389					{ state: @state }
390				end
391
392				def sql_query
393					["SELECT * FROM tel_inventory WHERE region = ?", @state]
394				end
395
396				def to_s
397					@state
398				end
399			end
400
401			class CityState
402				Q.register(/\A([^,]+)\s*,\s*([a-zA-Z]{2})\Z/, &method(:new))
403
404				CITY_MAP = {
405					"ajax" => "Ajax-Pickering",
406					"kitchener" => "Kitchener-Waterloo",
407					"new york" => "New York City",
408					"pickering" => "Ajax-Pickering",
409					"sault ste marie" => "sault sainte marie",
410					"sault ste. marie" => "sault sainte marie",
411					"south durham" => "Durham",
412					"township of langley" => "Langley",
413					"waterloo" => "Kitchener-Waterloo",
414					"west durham" => "Durham"
415				}.freeze
416
417				def initialize(city, state, db: DB, memcache: MEMCACHE)
418					@city = CITY_MAP.fetch(city.downcase, city)
419					@state = State.new(state)
420					@db = db
421					@memcache = memcache
422				end
423
424				def fallback
425					LazyObject.new do
426						AreaCodeRepo.new(
427							db: @db,
428							geo_code_repo: GeoCodeRepo.new(memcache: @memcache)
429						).find(to_s).sync.map { |area_code|
430							AreaCode.new(area_code)
431						}
432					end
433				end
434
435				def iris_query
436					@state.iris_query.merge(city: @city)
437				end
438
439				def sql_query
440					[
441						"SELECT * FROM tel_inventory WHERE region = ? AND locality = ?",
442						@state.to_s, @city
443					]
444				end
445
446				def to_s
447					"#{@city}, #{@state}"
448				end
449			end
450		end
451	end
452end