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(qs, 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			def initialize(iris_query, fallback: [], memcache: MEMCACHE)
 95				@iris_query = iris_query
 96				@fallback = fallback
 97				@memcache = memcache
 98			end
 99
100			def tns
101				Command.log.debug("BandwidthIris::AvailableNumber.list", @iris_query)
102				unless (result = fetch_cache)
103					result = BandwidthIris::AvailableNumber.list(@iris_query)
104				end
105				return next_fallback if result.empty? && !@fallback.empty?
106
107				result.map { |tn| Tn.new(**tn) }
108			end
109
110			def next_fallback
111				@memcache.set(cache_key, CBOR.encode([]), 43200)
112				self.class.new(
113					@fallback.shift.iris_query.merge(
114						enableTNDetail: true, quantity: @iris_query[:quantity]
115					),
116					fallback: @fallback,
117					memcache: @memcache
118				).tns
119			end
120
121			def fetch_cache
122				promise = EMPromise.new
123				@memcache.get(cache_key, &promise.method(:fulfill))
124				result = promise.sync
125				result ? CBOR.decode(result) : nil
126			end
127
128			def cache_key
129				"BandwidthIris_#{@iris_query.to_a.flatten.join(',')}"
130			end
131
132			class Quantity
133				def self.for(form)
134					rsm_max = form.find(
135						"ns:set/ns:max",
136						ns: "http://jabber.org/protocol/rsm"
137					).first
138					if rsm_max
139						new(rsm_max.content.to_i)
140					else
141						Default.new
142					end
143				end
144
145				def initialize(quantity)
146					@quantity = quantity
147				end
148
149				def iris_query
150					{ quantity: @quantity }
151				end
152
153				# NOTE: Gajim sends back the whole list on submit, so big
154				# lists can cause issues
155				class Default
156					def iris_query
157						{ quantity: 10 }
158					end
159				end
160			end
161		end
162
163		class Tn
164			attr_reader :tel
165
166			def initialize(full_number:, city:, state:, **)
167				@tel = "+1#{full_number}"
168				@locality = city
169				@region = state
170			end
171
172			def formatted_tel
173				@tel =~ /\A\+1(\d{3})(\d{3})(\d+)\Z/
174				"(#{$1}) #{$2}-#{$3}"
175			end
176
177			def option
178				op = Blather::Stanza::X::Field::Option.new(value: tel, label: to_s)
179				op << reference
180				op
181			end
182
183			def reference
184				Nokogiri::XML::Builder.new { |xml|
185					xml.reference(
186						xmlns: "urn:xmpp:reference:0",
187						begin: 0,
188						end: formatted_tel.length - 1,
189						type: "data",
190						uri: "tel:#{tel}"
191					)
192				}.doc.root
193			end
194
195			def to_s
196				"#{formatted_tel} (#{@locality}, #{@region})"
197			end
198		end
199
200		class Q
201			def self.register(regex, &block)
202				@queries ||= []
203				@queries << [regex, block]
204			end
205
206			def self.for(q, **kwa)
207				@queries.each do |(regex, block)|
208					match_data = (q =~ regex)
209					return block.call($1 || $&, *$~.to_a[2..-1], **kwa) if match_data
210				end
211
212				raise "Format not recognized: #{q}"
213			end
214
215			def initialize(q)
216				@q = q
217			end
218
219			def fallback
220				[]
221			end
222
223			{
224				areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
225				npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
226				npaNxxx: [:NpaNxxx, /\A(?:[2-9][0-9]{2}){2}[0-9]\Z/],
227				zip: [:PostalCode, /\A\d{5}(?:-\d{4})?\Z/],
228				localVanity: [:LocalVanity, /\A~(.+)\Z/]
229			}.each do |k, args|
230				klass = const_set(
231					args[0],
232					Class.new(Q) {
233						define_method(:iris_query) do
234							{ k => @q }
235						end
236					}
237				)
238
239				args[1..-1].each do |regex|
240					register(regex) { |q, **| klass.new(q) }
241				end
242			end
243
244			class State
245				Q.register(/\A[a-zA-Z]{2}\Z/, &method(:new))
246
247				STATE_MAP = {
248					"QC" => "PQ"
249				}.freeze
250
251				def initialize(state, **)
252					@state = STATE_MAP.fetch(state.upcase, state.upcase)
253				end
254
255				def fallback
256					[]
257				end
258
259				def iris_query
260					{ state: @state }
261				end
262
263				def to_s
264					@state
265				end
266			end
267
268			class CityState
269				Q.register(/\A([^,]+)\s*,\s*([a-zA-Z]{2})\Z/, &method(:new))
270
271				CITY_MAP = {
272					"ajax" => "Ajax-Pickering",
273					"kitchener" => "Kitchener-Waterloo",
274					"new york" => "New York City",
275					"pickering" => "Ajax-Pickering",
276					"sault ste marie" => "sault sainte marie",
277					"sault ste. marie" => "sault sainte marie",
278					"south durham" => "Durham",
279					"township of langley" => "Langley",
280					"waterloo" => "Kitchener-Waterloo",
281					"west durham" => "Durham"
282				}.freeze
283
284				def initialize(city, state, db: DB, memcache: MEMCACHE)
285					@city = CITY_MAP.fetch(city.downcase, city)
286					@state = State.new(state)
287					@db = db
288					@memcache = memcache
289				end
290
291				def fallback
292					LazyObject.new do
293						AreaCodeRepo.new(
294							db: @db,
295							geo_code_repo: GeoCodeRepo.new(memcache: @memcache)
296						).find(to_s).sync.map { |area_code|
297							AreaCode.new(area_code)
298						}
299					end
300				end
301
302				def iris_query
303					@state.iris_query.merge(city: @city)
304				end
305
306				def to_s
307					"#{@city}, #{@state}"
308				end
309			end
310		end
311	end
312end