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			end
262
263			class LocalInventory < SimpleDelegator
264				def pending_value
265					"LocalInventory/#{tel}"
266				end
267			end
268		end
269
270		class Q
271			def self.register(regex, &block)
272				@queries ||= []
273				@queries << [regex, block]
274			end
275
276			def self.for(q, **kwa)
277				q = replace_region_names(q) unless q.start_with?("~")
278
279				@queries.each do |(regex, block)|
280					match_data = (q =~ regex)
281					return block.call($1 || $&, *$~.to_a[2..-1], **kwa) if match_data
282				end
283
284				raise "Format not recognized: #{q}"
285			end
286
287			def self.replace_region_names(query)
288				ISO3166::Country[:US].subdivisions.merge(
289					ISO3166::Country[:CA].subdivisions
290				).reduce(query) do |q, (code, region)|
291					([region.name] + Array(region.unofficial_names))
292						.reduce(q) do |r, name|
293							r.sub(/#{name}\s*(?!,)/i, code)
294						end
295				end
296			end
297
298			def initialize(q, **)
299				@q = q
300			end
301
302			def fallback
303				[]
304			end
305
306			{
307				areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
308				npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
309				npaNxxx: [:NpaNxxx, /\A(?:[2-9][0-9]{2}){2}[0-9]\Z/]
310			}.each do |k, args|
311				klass = const_set(
312					args[0],
313					Class.new(Q) {
314						define_method(:iris_query) do
315							{ k => @q }
316						end
317
318						define_method(:sql_query) do
319							["SELECT * FROM tel_inventory WHERE tel LIKE ?", "+1#{@q}%"]
320						end
321					}
322				)
323
324				args[1..-1].each do |regex|
325					register(regex) { |q, **| klass.new(q) }
326				end
327			end
328
329			class PostalCode < Q
330				Q.register(/\A\d{5}(?:-\d{4})?\Z/, &method(:new))
331
332				def iris_query
333					{ zip: @q }
334				end
335
336				def sql_query
337					nil
338				end
339			end
340
341			class LocalVanity < Q
342				Q.register(/\A~(.+)\Z/, &method(:new))
343
344				def iris_query
345					{ localVanity: @q }
346				end
347
348				def sql_query
349					["SELECT * FROM tel_inventory WHERE tel LIKE ?", "%#{q_digits}%"]
350				end
351
352				def q_digits
353					@q
354						.gsub(/[ABC]/i, "2")
355						.gsub(/[DEF]/i, "3")
356						.gsub(/[GHI]/i, "4")
357						.gsub(/[JKL]/i, "5")
358						.gsub(/[MNO]/i, "6")
359						.gsub(/[PQRS]/i, "7")
360						.gsub(/[TUV]/i, "8")
361						.gsub(/[WXYZ]/i, "9")
362				end
363			end
364
365			class State
366				Q.register(/\A[a-zA-Z]{2}\Z/, &method(:new))
367
368				STATE_MAP = {
369					"QC" => "PQ"
370				}.freeze
371
372				def initialize(state, **)
373					@state = STATE_MAP.fetch(state.upcase, state.upcase)
374				end
375
376				def fallback
377					[]
378				end
379
380				def iris_query
381					{ state: @state }
382				end
383
384				def sql_query
385					["SELECT * FROM tel_inventory WHERE region = ?", @state]
386				end
387
388				def to_s
389					@state
390				end
391			end
392
393			class CityState
394				Q.register(/\A([^,]+)\s*,\s*([a-zA-Z]{2})\Z/, &method(:new))
395
396				CITY_MAP = {
397					"ajax" => "Ajax-Pickering",
398					"kitchener" => "Kitchener-Waterloo",
399					"new york" => "New York City",
400					"pickering" => "Ajax-Pickering",
401					"sault ste marie" => "sault sainte marie",
402					"sault ste. marie" => "sault sainte marie",
403					"south durham" => "Durham",
404					"township of langley" => "Langley",
405					"waterloo" => "Kitchener-Waterloo",
406					"west durham" => "Durham"
407				}.freeze
408
409				def initialize(city, state, db: DB, memcache: MEMCACHE)
410					@city = CITY_MAP.fetch(city.downcase, city)
411					@state = State.new(state)
412					@db = db
413					@memcache = memcache
414				end
415
416				def fallback
417					LazyObject.new do
418						AreaCodeRepo.new(
419							db: @db,
420							geo_code_repo: GeoCodeRepo.new(memcache: @memcache)
421						).find(to_s).sync.map { |area_code|
422							AreaCode.new(area_code)
423						}
424					end
425				end
426
427				def iris_query
428					@state.iris_query.merge(city: @city)
429				end
430
431				def sql_query
432					[
433						"SELECT * FROM tel_inventory WHERE region = ? AND locality = ?",
434						@state.to_s, @city
435					]
436				end
437
438				def to_s
439					"#{@city}, #{@state}"
440				end
441			end
442		end
443	end
444end