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