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 set_tel(jid, tel)
 26		ChooseTel::Tn::LocalInventory.fetch(tel).then do |local_inv|
 27			set(
 28				jid,
 29				local_inv || ChooseTel::Tn::Bandwidth.new(ChooseTel::Tn.new(tel))
 30			)
 31		end
 32	end
 33
 34	def delete(jid)
 35		@redis.del("pending_tel_for-#{jid}")
 36	end
 37
 38	def [](jid)
 39		@redis.get("pending_tel_for-#{jid}").then do |tel|
 40			tel ? HaveTel.new(tel) : ChooseTel.new(db: @db, memcache: @memcache)
 41		end
 42	end
 43
 44	class HaveTel
 45		def initialize(tel)
 46			@tel = ChooseTel::Tn.for_pending_value(tel)
 47		end
 48
 49		def choose_tel
 50			EMPromise.resolve(@tel)
 51		end
 52	end
 53
 54	class ChooseTel
 55		def initialize(db: DB, memcache: MEMCACHE)
 56			@db = db
 57			@memcache = memcache
 58		end
 59
 60		def choose_tel(error: nil)
 61			Command.reply { |reply|
 62				reply.allowed_actions = [:next]
 63				reply.command << FormTemplate.render("tn_search", error: error)
 64			}.then do |iq|
 65				available = AvailableNumber.for(iq.form, db: @db, memcache: @memcache)
 66				next available if available.is_a?(String)
 67
 68				choose_from_list(available.tns)
 69			rescue StandardError
 70				choose_tel(error: $!.to_s)
 71			end
 72		end
 73
 74		def choose_from_list(tns)
 75			raise "No numbers found, try another search." if tns.empty?
 76
 77			Command.reply { |reply|
 78				reply.allowed_actions = [:next, :prev]
 79				reply.command << FormTemplate.render("tn_list", tns: tns)
 80			}.then { |iq|
 81				choose_from_list_result(tns, iq)
 82			}
 83		end
 84
 85		def choose_from_list_result(tns, iq)
 86			tel = iq.form.field("tel")&.value
 87			return choose_tel if iq.prev? || !tel
 88
 89			tns.find { |tn| tn.tel == tel } || Tn::Bandwidth.new(Tn.new(tel))
 90		end
 91
 92		class AvailableNumber
 93			def self.for(form, db: DB, memcache: MEMCACHE)
 94				qs = form.field("q")&.value.to_s.strip
 95				return Tn.for_pending_value(qs) if qs =~ /\A\+1\d{10}\Z/
 96
 97				q = Q.for(feelinglucky(qs, form), db: db, memcache: memcache)
 98
 99				new(
100					q.iris_query
101					.merge(enableTNDetail: true, LCA: false)
102					.merge(Quantity.for(form).iris_query),
103					q.sql_query,
104					fallback: q.fallback, memcache: memcache, db: db
105				)
106			end
107
108			ACTION_FIELD = "http://jabber.org/protocol/commands#actions"
109
110			def self.feelinglucky(q, form)
111				return q unless q.empty?
112				return q unless form.field(ACTION_FIELD)&.value == "feelinglucky"
113
114				"810"
115			end
116
117			def initialize(
118				iris_query, sql_query, fallback: [], memcache: MEMCACHE, db: DB
119			)
120				@iris_query = iris_query
121				@sql_query = sql_query
122				@fallback = fallback
123				@memcache = memcache
124				@db = db
125			end
126
127			def tns
128				Command.log.debug("BandwidthIris::AvailableNumber.list", @iris_query)
129				unless (result = fetch_cache)
130					result = fetch_bandwidth_inventory + fetch_local_inventory.sync
131				end
132				return next_fallback if result.empty? && !@fallback.empty?
133
134				result
135			end
136
137			def fetch_bandwidth_inventory
138				BandwidthIris::AvailableNumber
139					.list(@iris_query)
140					.map { |tn| Tn::Bandwidth.new(Tn::Option.new(**tn)) }
141			end
142
143			def fetch_local_inventory
144				@db.query_defer(@sql_query[0], @sql_query[1..-1]).then { |rows|
145					rows.map { |row|
146						Tn::LocalInventory.new(Tn::Option.new(
147							full_number: row["tel"].sub(/\A\+1/, ""),
148							city: row["locality"],
149							state: row["region"]
150						), row["bandwidth_account_id"])
151					}
152				}
153			end
154
155			def next_fallback
156				@memcache.set(cache_key, CBOR.encode([]), 43200)
157				fallback = @fallback.shift
158				self.class.new(
159					fallback.iris_query.merge(
160						enableTNDetail: true, quantity: @iris_query[:quantity]
161					),
162					fallback.sql_query,
163					fallback: @fallback,
164					memcache: @memcache, db: @db
165				).tns
166			end
167
168			def fetch_cache
169				promise = EMPromise.new
170				@memcache.get(cache_key, &promise.method(:fulfill))
171				result = promise.sync
172				result ? CBOR.decode(result) : nil
173			end
174
175			def cache_key
176				"BandwidthIris_#{@iris_query.to_a.flatten.join(',')}"
177			end
178
179			class Quantity
180				def self.for(form)
181					if form.field(ACTION_FIELD)&.value == "feelinglucky"
182						return Default.new
183					end
184
185					rsm_max = form.find(
186						"ns:set/ns:max",
187						ns: "http://jabber.org/protocol/rsm"
188					).first
189					return new(rsm_max.content.to_i) if rsm_max
190
191					Default.new
192				end
193
194				def initialize(quantity)
195					@quantity = quantity
196				end
197
198				def iris_query
199					{ quantity: [@quantity, 100].min }
200				end
201
202				# NOTE: Gajim sends back the whole list on submit, so big
203				# lists can cause issues
204				class Default
205					def iris_query
206						{ quantity: 10 }
207					end
208				end
209			end
210		end
211
212		class Tn
213			attr_reader :tel
214
215			def self.for_pending_value(value)
216				if value.start_with?("LocalInventory/")
217					tel, account = value.sub(/\ALocalInventory\//, "").split("/", 2)
218					LocalInventory.new(Tn.new(tel), account)
219				else
220					Bandwidth.new(Tn.new(value))
221				end
222			end
223
224			def initialize(tel)
225				@tel = tel
226			end
227
228			def formatted_tel
229				@tel =~ /\A\+1(\d{3})(\d{3})(\d+)\Z/
230				"(#{$1}) #{$2}-#{$3}"
231			end
232
233			def to_s
234				formatted_tel
235			end
236
237			class Option < Tn
238				def initialize(full_number:, city:, state:, **)
239					@tel = "+1#{full_number}"
240					@locality = city
241					@region = state
242				end
243
244				def option
245					op = Blather::Stanza::X::Field::Option.new(value: tel, label: to_s)
246					op << reference
247					op
248				end
249
250				def reference
251					Nokogiri::XML::Builder.new { |xml|
252						xml.reference(
253							xmlns: "urn:xmpp:reference:0",
254							begin: 0,
255							end: formatted_tel.length - 1,
256							type: "data",
257							uri: "tel:#{tel}"
258						)
259					}.doc.root
260				end
261
262				def to_s
263					"#{formatted_tel} (#{@locality}, #{@region})"
264				end
265			end
266
267			class Bandwidth < SimpleDelegator
268				def pending_value
269					tel
270				end
271
272				def reserve(customer)
273					BandwidthTnReservationRepo.new.ensure(customer, tel)
274				end
275
276				def order(_, customer)
277					BandwidthTnReservationRepo.new.get(customer, tel).then do |rid|
278						BandwidthTNOrder.create(
279							tel,
280							customer_order_id: customer.customer_id,
281							reservation_id: rid
282						).then(&:poll)
283					end
284				end
285			end
286
287			class LocalInventory < SimpleDelegator
288				def self.fetch(tn, db: DB)
289					db.query_defer("SELECT * FROM tel_inventory WHERE tel = $1", [tn])
290						.then { |rows|
291						rows.first&.then { |row|
292							new(Tn::Option.new(
293								full_number: row["tel"].sub(/\A\+1/, ""),
294								city: row["locality"],
295								state: row["region"]
296							), row["bandwidth_account_id"])
297						}
298					}
299				end
300
301				def initialize(tn, bandwidth_account_id)
302					super(tn)
303					@bandwidth_account_id = bandwidth_account_id
304				end
305
306				def pending_value
307					"LocalInventory/#{tel}/#{@bandwidth_account_id}"
308				end
309
310				def reserve(*)
311					EMPromise.resolve(nil)
312				end
313
314				def order(db, _customer)
315					# Move always moves to wrong account, oops
316					# Also probably can't move from/to same account
317					# BandwidthTnRepo.new.move(
318					# 	tel, customer.customer_id, @bandwidth_account_id
319					# )
320					db.exec_defer("DELETE FROM tel_inventory WHERE tel = $1", [tel])
321						.then { |r| raise unless r.cmd_tuples.positive? }
322				end
323			end
324		end
325
326		class Q
327			def self.register(regex, &block)
328				@queries ||= []
329				@queries << [regex, block]
330			end
331
332			def self.for(q, **kwa)
333				q = replace_region_names(q) unless q.start_with?("~")
334
335				@queries.each do |(regex, block)|
336					match_data = (q =~ regex)
337					return block.call($1 || $&, *$~.to_a[2..-1], **kwa) if match_data
338				end
339
340				raise "Format not recognized: #{q}"
341			end
342
343			def self.replace_region_names(query)
344				ISO3166::Country[:US].subdivisions.merge(
345					ISO3166::Country[:CA].subdivisions
346				).reduce(query) do |q, (code, region)|
347					([region.name] + Array(region.unofficial_names))
348						.reduce(q) do |r, name|
349							r.sub(/#{name}\s*(?!,)/i, code)
350						end
351				end
352			end
353
354			def initialize(q, **)
355				@q = q
356			end
357
358			def fallback
359				[]
360			end
361
362			{
363				areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
364				npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
365				npaNxxx: [:NpaNxxx, /\A(?:[2-9][0-9]{2}){2}[0-9]\Z/]
366			}.each do |k, args|
367				klass = const_set(
368					args[0],
369					Class.new(Q) {
370						define_method(:iris_query) do
371							{ k => @q }
372						end
373
374						define_method(:sql_query) do
375							[
376								"SELECT * FROM tel_inventory " \
377								"WHERE available_after < LOCALTIMESTAMP AND tel LIKE $1",
378								"+1#{@q}%"
379							]
380						end
381					}
382				)
383
384				args[1..-1].each do |regex|
385					register(regex) { |q, **| klass.new(q) }
386				end
387			end
388
389			class PostalCode < Q
390				Q.register(/\A\d{5}(?:-\d{4})?\Z/, &method(:new))
391
392				def iris_query
393					{ zip: @q }
394				end
395
396				def sql_query
397					nil
398				end
399			end
400
401			class LocalVanity < Q
402				Q.register(/\A~(.+)\Z/, &method(:new))
403
404				def iris_query
405					{ localVanity: @q }
406				end
407
408				def sql_query
409					[
410						"SELECT * FROM tel_inventory " \
411						"WHERE available_after < LOCALTIMESTAMP AND tel LIKE $1",
412						"%#{q_digits}%"
413					]
414				end
415
416				def q_digits
417					@q
418						.gsub(/[ABC]/i, "2")
419						.gsub(/[DEF]/i, "3")
420						.gsub(/[GHI]/i, "4")
421						.gsub(/[JKL]/i, "5")
422						.gsub(/[MNO]/i, "6")
423						.gsub(/[PQRS]/i, "7")
424						.gsub(/[TUV]/i, "8")
425						.gsub(/[WXYZ]/i, "9")
426				end
427			end
428
429			class State
430				Q.register(/\A[a-zA-Z]{2}\Z/, &method(:new))
431
432				STATE_MAP = {
433					"QC" => "PQ"
434				}.freeze
435
436				def initialize(state, **)
437					@state = STATE_MAP.fetch(state.upcase, state.upcase)
438				end
439
440				def fallback
441					[]
442				end
443
444				def iris_query
445					{ state: @state }
446				end
447
448				def sql_query
449					[
450						"SELECT * FROM tel_inventory " \
451						"WHERE available_after < LOCALTIMESTAMP AND region = $1",
452						@state
453					]
454				end
455
456				def to_s
457					@state
458				end
459			end
460
461			class CityState
462				Q.register(/\A([^,]+)\s*,\s*([a-zA-Z]{2})\Z/, &method(:new))
463
464				CITY_MAP = {
465					"ajax" => "Ajax-Pickering",
466					"kitchener" => "Kitchener-Waterloo",
467					"new york" => "New York City",
468					"pickering" => "Ajax-Pickering",
469					"sault ste marie" => "sault sainte marie",
470					"sault ste. marie" => "sault sainte marie",
471					"south durham" => "Durham",
472					"township of langley" => "Langley",
473					"waterloo" => "Kitchener-Waterloo",
474					"west durham" => "Durham"
475				}.freeze
476
477				def initialize(city, state, db: DB, memcache: MEMCACHE)
478					@city = CITY_MAP.fetch(city.downcase, city)
479					@state = State.new(state)
480					@db = db
481					@memcache = memcache
482				end
483
484				def fallback
485					LazyObject.new do
486						AreaCodeRepo.new(
487							db: @db,
488							geo_code_repo: GeoCodeRepo.new(memcache: @memcache)
489						).find(to_s).sync.map { |area_code|
490							AreaCode.new(area_code)
491						}
492					end
493				end
494
495				def iris_query
496					@state.iris_query.merge(city: @city)
497				end
498
499				def sql_query
500					[
501						"SELECT * FROM tel_inventory " \
502						"WHERE available_after < LOCALTIMESTAMP " \
503						"AND region = $1 AND locality = $2",
504						@state.to_s, @city
505					]
506				end
507
508				def to_s
509					"#{@city}, #{@state}"
510				end
511			end
512		end
513	end
514end