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