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