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