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