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)
23 end
24
25 def delete(jid)
26 @redis.del("pending_tel_for-#{jid}")
27 end
28
29 def [](jid)
30 @redis.get("pending_tel_for-#{jid}").then do |tel|
31 tel ? HaveTel.new(tel) : ChooseTel.new(db: @db, memcache: @memcache)
32 end
33 end
34
35 class HaveTel
36 def initialize(tel)
37 @tel = tel
38 end
39
40 def choose_tel
41 EMPromise.resolve(@tel)
42 end
43 end
44
45 class ChooseTel
46 def initialize(db: DB, memcache: MEMCACHE)
47 @db = db
48 @memcache = memcache
49 end
50
51 def choose_tel(error: nil)
52 Command.reply { |reply|
53 reply.allowed_actions = [:next]
54 reply.command << FormTemplate.render("tn_search", error: error)
55 }.then do |iq|
56 available = AvailableNumber.for(iq.form, db: @db, memcache: @memcache)
57 next available if available.is_a?(String)
58
59 choose_from_list(available.tns)
60 rescue StandardError
61 choose_tel(error: $!.to_s)
62 end
63 end
64
65 def choose_from_list(tns)
66 raise "No numbers found, try another search." if tns.empty?
67
68 Command.reply { |reply|
69 reply.allowed_actions = [:next, :prev]
70 reply.command << FormTemplate.render("tn_list", tns: tns)
71 }.then { |iq|
72 tel = iq.form.field("tel")&.value
73 next choose_tel if iq.prev? || !tel
74
75 tel.to_s.strip
76 }
77 end
78
79 class AvailableNumber
80 def self.for(form, db: DB, memcache: MEMCACHE)
81 qs = form.field("q")&.value.to_s.strip
82 return qs if qs =~ /\A\+1\d{10}\Z/
83
84 q = Q.for(feelinglucky(qs, form), db: db, memcache: memcache)
85
86 new(
87 q.iris_query
88 .merge(enableTNDetail: true, LCA: false)
89 .merge(Quantity.for(form).iris_query),
90 q.sql_query,
91 fallback: q.fallback, memcache: memcache, db: db
92 )
93 end
94
95 ACTION_FIELD = "http://jabber.org/protocol/commands#actions"
96
97 def self.feelinglucky(q, form)
98 return q unless q.empty?
99 return q unless form.field(ACTION_FIELD)&.value == "feelinglucky"
100
101 "810"
102 end
103
104 def initialize(
105 iris_query, sql_query, fallback: [], memcache: MEMCACHE, db: DB
106 )
107 @iris_query = iris_query
108 @sql_query = sql_query
109 @fallback = fallback
110 @memcache = memcache
111 @db = db
112 end
113
114 def tns
115 Command.log.debug("BandwidthIris::AvailableNumber.list", @iris_query)
116 unless (result = fetch_cache)
117 result =
118 BandwidthIris::AvailableNumber.list(@iris_query) +
119 fetch_local_inventory.sync
120 end
121 return next_fallback if result.empty? && !@fallback.empty?
122
123 result.map { |tn| Tn.new(**tn) }
124 end
125
126 def fetch_local_inventory
127 @db.query_defer(@sql_query[0], @sql_query[1..-1]).then { |rows|
128 rows.map { |row|
129 {
130 full_number: row["tel"].sub(/\A\+1/, ""),
131 city: row["locality"],
132 state: row["region"]
133 }
134 }
135 }
136 end
137
138 def next_fallback
139 @memcache.set(cache_key, CBOR.encode([]), 43200)
140 fallback = @fallback.shift
141 self.class.new(
142 fallback.iris_query.merge(
143 enableTNDetail: true, quantity: @iris_query[:quantity]
144 ),
145 fallback.sql_query,
146 fallback: @fallback,
147 memcache: @memcache, db: @db
148 ).tns
149 end
150
151 def fetch_cache
152 promise = EMPromise.new
153 @memcache.get(cache_key, &promise.method(:fulfill))
154 result = promise.sync
155 result ? CBOR.decode(result) : nil
156 end
157
158 def cache_key
159 "BandwidthIris_#{@iris_query.to_a.flatten.join(',')}"
160 end
161
162 class Quantity
163 def self.for(form)
164 if form.field(ACTION_FIELD)&.value == "feelinglucky"
165 return Default.new
166 end
167
168 rsm_max = form.find(
169 "ns:set/ns:max",
170 ns: "http://jabber.org/protocol/rsm"
171 ).first
172 return new(rsm_max.content.to_i) if rsm_max
173
174 Default.new
175 end
176
177 def initialize(quantity)
178 @quantity = quantity
179 end
180
181 def iris_query
182 { quantity: @quantity }
183 end
184
185 # NOTE: Gajim sends back the whole list on submit, so big
186 # lists can cause issues
187 class Default
188 def iris_query
189 { quantity: 10 }
190 end
191 end
192 end
193 end
194
195 class Tn
196 attr_reader :tel
197
198 def initialize(full_number:, city:, state:, **)
199 @tel = "+1#{full_number}"
200 @locality = city
201 @region = state
202 end
203
204 def formatted_tel
205 @tel =~ /\A\+1(\d{3})(\d{3})(\d+)\Z/
206 "(#{$1}) #{$2}-#{$3}"
207 end
208
209 def option
210 op = Blather::Stanza::X::Field::Option.new(value: tel, label: to_s)
211 op << reference
212 op
213 end
214
215 def reference
216 Nokogiri::XML::Builder.new { |xml|
217 xml.reference(
218 xmlns: "urn:xmpp:reference:0",
219 begin: 0,
220 end: formatted_tel.length - 1,
221 type: "data",
222 uri: "tel:#{tel}"
223 )
224 }.doc.root
225 end
226
227 def to_s
228 "#{formatted_tel} (#{@locality}, #{@region})"
229 end
230 end
231
232 class Q
233 def self.register(regex, &block)
234 @queries ||= []
235 @queries << [regex, block]
236 end
237
238 def self.for(q, **kwa)
239 q = replace_region_names(q) unless q.start_with?("~")
240
241 @queries.each do |(regex, block)|
242 match_data = (q =~ regex)
243 return block.call($1 || $&, *$~.to_a[2..-1], **kwa) if match_data
244 end
245
246 raise "Format not recognized: #{q}"
247 end
248
249 def self.replace_region_names(query)
250 ISO3166::Country[:US].subdivisions.merge(
251 ISO3166::Country[:CA].subdivisions
252 ).reduce(query) do |q, (code, region)|
253 ([region.name] + Array(region.unofficial_names))
254 .reduce(q) do |r, name|
255 r.sub(/#{name}\s*(?!,)/i, code)
256 end
257 end
258 end
259
260 def initialize(q, **)
261 @q = q
262 end
263
264 def fallback
265 []
266 end
267
268 {
269 areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
270 npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
271 npaNxxx: [:NpaNxxx, /\A(?:[2-9][0-9]{2}){2}[0-9]\Z/]
272 }.each do |k, args|
273 klass = const_set(
274 args[0],
275 Class.new(Q) {
276 define_method(:iris_query) do
277 { k => @q }
278 end
279
280 define_method(:sql_query) do
281 ["SELECT * FROM tel_inventory WHERE tel LIKE ?", "+1#{@q}%"]
282 end
283 }
284 )
285
286 args[1..-1].each do |regex|
287 register(regex) { |q, **| klass.new(q) }
288 end
289 end
290
291 class PostalCode < Q
292 Q.register(/\A\d{5}(?:-\d{4})?\Z/, &method(:new))
293
294 def iris_query
295 { zip: @q }
296 end
297
298 def sql_query
299 nil
300 end
301 end
302
303 class LocalVanity < Q
304 Q.register(/\A~(.+)\Z/, &method(:new))
305
306 def iris_query
307 { localVanity: @q }
308 end
309
310 def sql_query
311 ["SELECT * FROM tel_inventory WHERE tel LIKE ?", "%#{q_digits}%"]
312 end
313
314 def q_digits
315 @q
316 .gsub(/[ABC]/i, "2")
317 .gsub(/[DEF]/i, "3")
318 .gsub(/[GHI]/i, "4")
319 .gsub(/[JKL]/i, "5")
320 .gsub(/[MNO]/i, "6")
321 .gsub(/[PQRS]/i, "7")
322 .gsub(/[TUV]/i, "8")
323 .gsub(/[WXYZ]/i, "9")
324 end
325 end
326
327 class State
328 Q.register(/\A[a-zA-Z]{2}\Z/, &method(:new))
329
330 STATE_MAP = {
331 "QC" => "PQ"
332 }.freeze
333
334 def initialize(state, **)
335 @state = STATE_MAP.fetch(state.upcase, state.upcase)
336 end
337
338 def fallback
339 []
340 end
341
342 def iris_query
343 { state: @state }
344 end
345
346 def sql_query
347 ["SELECT * FROM tel_inventory WHERE region = ?", @state]
348 end
349
350 def to_s
351 @state
352 end
353 end
354
355 class CityState
356 Q.register(/\A([^,]+)\s*,\s*([a-zA-Z]{2})\Z/, &method(:new))
357
358 CITY_MAP = {
359 "ajax" => "Ajax-Pickering",
360 "kitchener" => "Kitchener-Waterloo",
361 "new york" => "New York City",
362 "pickering" => "Ajax-Pickering",
363 "sault ste marie" => "sault sainte marie",
364 "sault ste. marie" => "sault sainte marie",
365 "south durham" => "Durham",
366 "township of langley" => "Langley",
367 "waterloo" => "Kitchener-Waterloo",
368 "west durham" => "Durham"
369 }.freeze
370
371 def initialize(city, state, db: DB, memcache: MEMCACHE)
372 @city = CITY_MAP.fetch(city.downcase, city)
373 @state = State.new(state)
374 @db = db
375 @memcache = memcache
376 end
377
378 def fallback
379 LazyObject.new do
380 AreaCodeRepo.new(
381 db: @db,
382 geo_code_repo: GeoCodeRepo.new(memcache: @memcache)
383 ).find(to_s).sync.map { |area_code|
384 AreaCode.new(area_code)
385 }
386 end
387 end
388
389 def iris_query
390 @state.iris_query.merge(city: @city)
391 end
392
393 def sql_query
394 [
395 "SELECT * FROM tel_inventory WHERE region = ? AND locality = ?",
396 @state.to_s, @city
397 ]
398 end
399
400 def to_s
401 "#{@city}, #{@state}"
402 end
403 end
404 end
405 end
406end