tel_selections.rb

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