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 fallback: q.fallback,
91 memcache: memcache
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(iris_query, fallback: [], memcache: MEMCACHE)
105 @iris_query = iris_query
106 @fallback = fallback
107 @memcache = memcache
108 end
109
110 def tns
111 Command.log.debug("BandwidthIris::AvailableNumber.list", @iris_query)
112 unless (result = fetch_cache)
113 result = BandwidthIris::AvailableNumber.list(@iris_query)
114 end
115 return next_fallback if result.empty? && !@fallback.empty?
116
117 result.map { |tn| Tn.new(**tn) }
118 end
119
120 def next_fallback
121 @memcache.set(cache_key, CBOR.encode([]), 43200)
122 self.class.new(
123 @fallback.shift.iris_query.merge(
124 enableTNDetail: true, quantity: @iris_query[:quantity]
125 ),
126 fallback: @fallback,
127 memcache: @memcache
128 ).tns
129 end
130
131 def fetch_cache
132 promise = EMPromise.new
133 @memcache.get(cache_key, &promise.method(:fulfill))
134 result = promise.sync
135 result ? CBOR.decode(result) : nil
136 end
137
138 def cache_key
139 "BandwidthIris_#{@iris_query.to_a.flatten.join(',')}"
140 end
141
142 class Quantity
143 def self.for(form)
144 if form.field(ACTION_FIELD)&.value == "feelinglucky"
145 return Default.new
146 end
147
148 rsm_max = form.find(
149 "ns:set/ns:max",
150 ns: "http://jabber.org/protocol/rsm"
151 ).first
152 return new(rsm_max.content.to_i) if rsm_max
153
154 Default.new
155 end
156
157 def initialize(quantity)
158 @quantity = quantity
159 end
160
161 def iris_query
162 { quantity: @quantity }
163 end
164
165 # NOTE: Gajim sends back the whole list on submit, so big
166 # lists can cause issues
167 class Default
168 def iris_query
169 { quantity: 10 }
170 end
171 end
172 end
173 end
174
175 class Tn
176 attr_reader :tel
177
178 def initialize(full_number:, city:, state:, **)
179 @tel = "+1#{full_number}"
180 @locality = city
181 @region = state
182 end
183
184 def formatted_tel
185 @tel =~ /\A\+1(\d{3})(\d{3})(\d+)\Z/
186 "(#{$1}) #{$2}-#{$3}"
187 end
188
189 def option
190 op = Blather::Stanza::X::Field::Option.new(value: tel, label: to_s)
191 op << reference
192 op
193 end
194
195 def reference
196 Nokogiri::XML::Builder.new { |xml|
197 xml.reference(
198 xmlns: "urn:xmpp:reference:0",
199 begin: 0,
200 end: formatted_tel.length - 1,
201 type: "data",
202 uri: "tel:#{tel}"
203 )
204 }.doc.root
205 end
206
207 def to_s
208 "#{formatted_tel} (#{@locality}, #{@region})"
209 end
210 end
211
212 class Q
213 def self.register(regex, &block)
214 @queries ||= []
215 @queries << [regex, block]
216 end
217
218 def self.for(q, **kwa)
219 q = replace_region_names(q) unless q.start_with?("~")
220
221 @queries.each do |(regex, block)|
222 match_data = (q =~ regex)
223 return block.call($1 || $&, *$~.to_a[2..-1], **kwa) if match_data
224 end
225
226 raise "Format not recognized: #{q}"
227 end
228
229 def self.replace_region_names(query)
230 ISO3166::Country[:US].subdivisions.merge(
231 ISO3166::Country[:CA].subdivisions
232 ).reduce(query) do |q, (code, region)|
233 ([region.name] + Array(region.unofficial_names))
234 .reduce(q) do |r, name|
235 r.sub(/#{name}\s*(?!,)/i, code)
236 end
237 end
238 end
239
240 def initialize(q, **)
241 @q = q
242 end
243
244 def fallback
245 []
246 end
247
248 {
249 areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
250 npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
251 npaNxxx: [:NpaNxxx, /\A(?:[2-9][0-9]{2}){2}[0-9]\Z/]
252 }.each do |k, args|
253 klass = const_set(
254 args[0],
255 Class.new(Q) {
256 define_method(:iris_query) do
257 { k => @q }
258 end
259
260 define_method(:sql_query) do
261 ["SELECT * FROM tel_inventory WHERE tel LIKE ?", "+1#{@q}%"]
262 end
263 }
264 )
265
266 args[1..-1].each do |regex|
267 register(regex) { |q, **| klass.new(q) }
268 end
269 end
270
271 class PostalCode < Q
272 Q.register(/\A\d{5}(?:-\d{4})?\Z/, &method(:new))
273
274 def iris_query
275 { zip: @q }
276 end
277
278 def sql_query
279 nil
280 end
281 end
282
283 class LocalVanity < Q
284 Q.register(/\A~(.+)\Z/, &method(:new))
285
286 def iris_query
287 { localVanity: @q }
288 end
289
290 def sql_query
291 ["SELECT * FROM tel_inventory WHERE tel LIKE ?", "%#{q_digits}%"]
292 end
293
294 def q_digits
295 @q
296 .gsub(/[ABC]/i, "2")
297 .gsub(/[DEF]/i, "3")
298 .gsub(/[GHI]/i, "4")
299 .gsub(/[JKL]/i, "5")
300 .gsub(/[MNO]/i, "6")
301 .gsub(/[PQRS]/i, "7")
302 .gsub(/[TUV]/i, "8")
303 .gsub(/[WXYZ]/i, "9")
304 end
305 end
306
307 class State
308 Q.register(/\A[a-zA-Z]{2}\Z/, &method(:new))
309
310 STATE_MAP = {
311 "QC" => "PQ"
312 }.freeze
313
314 def initialize(state, **)
315 @state = STATE_MAP.fetch(state.upcase, state.upcase)
316 end
317
318 def fallback
319 []
320 end
321
322 def iris_query
323 { state: @state }
324 end
325
326 def sql_query
327 ["SELECT * FROM tel_inventory WHERE region = ?", @state]
328 end
329
330 def to_s
331 @state
332 end
333 end
334
335 class CityState
336 Q.register(/\A([^,]+)\s*,\s*([a-zA-Z]{2})\Z/, &method(:new))
337
338 CITY_MAP = {
339 "ajax" => "Ajax-Pickering",
340 "kitchener" => "Kitchener-Waterloo",
341 "new york" => "New York City",
342 "pickering" => "Ajax-Pickering",
343 "sault ste marie" => "sault sainte marie",
344 "sault ste. marie" => "sault sainte marie",
345 "south durham" => "Durham",
346 "township of langley" => "Langley",
347 "waterloo" => "Kitchener-Waterloo",
348 "west durham" => "Durham"
349 }.freeze
350
351 def initialize(city, state, db: DB, memcache: MEMCACHE)
352 @city = CITY_MAP.fetch(city.downcase, city)
353 @state = State.new(state)
354 @db = db
355 @memcache = memcache
356 end
357
358 def fallback
359 LazyObject.new do
360 AreaCodeRepo.new(
361 db: @db,
362 geo_code_repo: GeoCodeRepo.new(memcache: @memcache)
363 ).find(to_s).sync.map { |area_code|
364 AreaCode.new(area_code)
365 }
366 end
367 end
368
369 def iris_query
370 @state.iris_query.merge(city: @city)
371 end
372
373 def sql_query
374 [
375 "SELECT * FROM tel_inventory WHERE region = ? AND locality = ?",
376 @state.to_s, @city
377 ]
378 end
379
380 def to_s
381 "#{@city}, #{@state}"
382 end
383 end
384 end
385 end
386end