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