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