1# frozen_string_literal: true
2
3require "ruby-bandwidth-iris"
4Faraday.default_adapter = :em_synchrony
5
6require "cbor"
7
8require_relative "area_code_repo"
9require_relative "form_template"
10
11class TelSelections
12 THIRTY_DAYS = 60 * 60 * 24 * 30
13
14 def initialize(redis: REDIS, db: DB, memcache: MEMCACHE)
15 @redis = redis
16 @memcache = memcache
17 @db = db
18 end
19
20 def set(jid, tel)
21 @redis.setex("pending_tel_for-#{jid}", THIRTY_DAYS, tel)
22 end
23
24 def delete(jid)
25 @redis.del("pending_tel_for-#{jid}")
26 end
27
28 def [](jid)
29 @redis.get("pending_tel_for-#{jid}").then do |tel|
30 tel ? HaveTel.new(tel) : ChooseTel.new(db: @db, memcache: @memcache)
31 end
32 end
33
34 class HaveTel
35 def initialize(tel)
36 @tel = tel
37 end
38
39 def choose_tel
40 EMPromise.resolve(@tel)
41 end
42 end
43
44 class ChooseTel
45 def initialize(db: DB, memcache: MEMCACHE)
46 @db = db
47 @memcache = memcache
48 end
49
50 def choose_tel(error: nil)
51 Command.reply { |reply|
52 reply.allowed_actions = [:next]
53 reply.command << FormTemplate.render("tn_search", error: error)
54 }.then do |iq|
55 available = AvailableNumber.for(iq.form, db: @db, memcache: @memcache)
56 next available if available.is_a?(String)
57
58 choose_from_list(available.tns)
59 rescue StandardError
60 choose_tel(error: $!.to_s)
61 end
62 end
63
64 def choose_from_list(tns)
65 raise "No numbers found, try another search." if tns.empty?
66
67 Command.reply { |reply|
68 reply.allowed_actions = [:next, :prev]
69 reply.command << FormTemplate.render("tn_list", tns: tns)
70 }.then { |iq|
71 tel = iq.form.field("tel")&.value
72 next choose_tel if iq.prev? || !tel
73
74 tel.to_s.strip
75 }
76 end
77
78 class AvailableNumber
79 def self.for(form, db: DB, memcache: MEMCACHE)
80 qs = form.field("q")&.value.to_s.strip
81 return qs if qs =~ /\A\+1\d{10}\Z/
82
83 q = Q.for(qs, db: db, memcache: memcache)
84
85 new(
86 q.iris_query
87 .merge(enableTNDetail: true, LCA: false)
88 .merge(Quantity.for(form).iris_query),
89 fallback: q.fallback,
90 memcache: memcache
91 )
92 end
93
94 def initialize(iris_query, fallback: [], memcache: MEMCACHE)
95 @iris_query = iris_query
96 @fallback = fallback
97 @memcache = memcache
98 end
99
100 def tns
101 Command.log.debug("BandwidthIris::AvailableNumber.list", @iris_query)
102 unless (result = fetch_cache)
103 result = BandwidthIris::AvailableNumber.list(@iris_query)
104 end
105 return next_fallback if result.empty? && !@fallback.empty?
106
107 result.map { |tn| Tn.new(**tn) }
108 end
109
110 def next_fallback
111 @memcache.set(cache_key, CBOR.encode([]), 43200)
112 self.class.new(
113 @fallback.shift.iris_query.merge(
114 enableTNDetail: true, quantity: @iris_query[:quantity]
115 ),
116 fallback: @fallback,
117 memcache: @memcache
118 ).tns
119 end
120
121 def fetch_cache
122 promise = EMPromise.new
123 @memcache.get(cache_key, &promise.method(:fulfill))
124 result = promise.sync
125 result ? CBOR.decode(result) : nil
126 end
127
128 def cache_key
129 "BandwidthIris_#{@iris_query.to_a.flatten.join(',')}"
130 end
131
132 class Quantity
133 def self.for(form)
134 rsm_max = form.find(
135 "ns:set/ns:max",
136 ns: "http://jabber.org/protocol/rsm"
137 ).first
138 if rsm_max
139 new(rsm_max.content.to_i)
140 else
141 Default.new
142 end
143 end
144
145 def initialize(quantity)
146 @quantity = quantity
147 end
148
149 def iris_query
150 { quantity: @quantity }
151 end
152
153 # NOTE: Gajim sends back the whole list on submit, so big
154 # lists can cause issues
155 class Default
156 def iris_query
157 { quantity: 10 }
158 end
159 end
160 end
161 end
162
163 class Tn
164 attr_reader :tel
165
166 def initialize(full_number:, city:, state:, **)
167 @tel = "+1#{full_number}"
168 @locality = city
169 @region = state
170 end
171
172 def formatted_tel
173 @tel =~ /\A\+1(\d{3})(\d{3})(\d+)\Z/
174 "(#{$1}) #{$2}-#{$3}"
175 end
176
177 def option
178 op = Blather::Stanza::X::Field::Option.new(value: tel, label: to_s)
179 op << reference
180 op
181 end
182
183 def reference
184 Nokogiri::XML::Builder.new { |xml|
185 xml.reference(
186 xmlns: "urn:xmpp:reference:0",
187 begin: 0,
188 end: formatted_tel.length - 1,
189 type: "data",
190 uri: "tel:#{tel}"
191 )
192 }.doc.root
193 end
194
195 def to_s
196 "#{formatted_tel} (#{@locality}, #{@region})"
197 end
198 end
199
200 class Q
201 def self.register(regex, &block)
202 @queries ||= []
203 @queries << [regex, block]
204 end
205
206 def self.for(q, **kwa)
207 @queries.each do |(regex, block)|
208 match_data = (q =~ regex)
209 return block.call($1 || $&, *$~.to_a[2..-1], **kwa) if match_data
210 end
211
212 raise "Format not recognized: #{q}"
213 end
214
215 def initialize(q)
216 @q = q
217 end
218
219 def fallback
220 []
221 end
222
223 {
224 areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
225 npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
226 npaNxxx: [:NpaNxxx, /\A(?:[2-9][0-9]{2}){2}[0-9]\Z/],
227 zip: [:PostalCode, /\A\d{5}(?:-\d{4})?\Z/],
228 localVanity: [:LocalVanity, /\A~(.+)\Z/]
229 }.each do |k, args|
230 klass = const_set(
231 args[0],
232 Class.new(Q) {
233 define_method(:iris_query) do
234 { k => @q }
235 end
236 }
237 )
238
239 args[1..-1].each do |regex|
240 register(regex) { |q, **| klass.new(q) }
241 end
242 end
243
244 class State
245 Q.register(/\A[a-zA-Z]{2}\Z/, &method(:new))
246
247 STATE_MAP = {
248 "QC" => "PQ"
249 }.freeze
250
251 def initialize(state, **)
252 @state = STATE_MAP.fetch(state.upcase, state.upcase)
253 end
254
255 def fallback
256 []
257 end
258
259 def iris_query
260 { state: @state }
261 end
262
263 def to_s
264 @state
265 end
266 end
267
268 class CityState
269 Q.register(/\A([^,]+)\s*,\s*([a-zA-Z]{2})\Z/, &method(:new))
270
271 CITY_MAP = {
272 "ajax" => "Ajax-Pickering",
273 "kitchener" => "Kitchener-Waterloo",
274 "new york" => "New York City",
275 "pickering" => "Ajax-Pickering",
276 "sault ste marie" => "sault sainte marie",
277 "sault ste. marie" => "sault sainte marie",
278 "south durham" => "Durham",
279 "township of langley" => "Langley",
280 "waterloo" => "Kitchener-Waterloo",
281 "west durham" => "Durham"
282 }.freeze
283
284 def initialize(city, state, db: DB, memcache: MEMCACHE)
285 @city = CITY_MAP.fetch(city.downcase, city)
286 @state = State.new(state)
287 @db = db
288 @memcache = memcache
289 end
290
291 def fallback
292 LazyObject.new do
293 AreaCodeRepo.new(
294 db: @db,
295 geo_code_repo: GeoCodeRepo.new(memcache: @memcache)
296 ).find(to_s).sync.map { |area_code|
297 AreaCode.new(area_code)
298 }
299 end
300 end
301
302 def iris_query
303 @state.iris_query.merge(city: @city)
304 end
305
306 def to_s
307 "#{@city}, #{@state}"
308 end
309 end
310 end
311 end
312end