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(feelinglucky(qs, form), 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 ACTION_FIELD = "http://jabber.org/protocol/commands#actions"
95
96 def self.feelinglucky(q, form)
97 return q unless q.empty?
98 return q unless form.field(ACTION_FIELD)&.value == "feelinglucky"
99
100 "810"
101 end
102
103 def initialize(iris_query, fallback: [], memcache: MEMCACHE)
104 @iris_query = iris_query
105 @fallback = fallback
106 @memcache = memcache
107 end
108
109 def tns
110 Command.log.debug("BandwidthIris::AvailableNumber.list", @iris_query)
111 unless (result = fetch_cache)
112 result = BandwidthIris::AvailableNumber.list(@iris_query)
113 end
114 return next_fallback if result.empty? && !@fallback.empty?
115
116 result.map { |tn| Tn.new(**tn) }
117 end
118
119 def next_fallback
120 @memcache.set(cache_key, CBOR.encode([]), 43200)
121 self.class.new(
122 @fallback.shift.iris_query.merge(
123 enableTNDetail: true, quantity: @iris_query[:quantity]
124 ),
125 fallback: @fallback,
126 memcache: @memcache
127 ).tns
128 end
129
130 def fetch_cache
131 promise = EMPromise.new
132 @memcache.get(cache_key, &promise.method(:fulfill))
133 result = promise.sync
134 result ? CBOR.decode(result) : nil
135 end
136
137 def cache_key
138 "BandwidthIris_#{@iris_query.to_a.flatten.join(',')}"
139 end
140
141 class Quantity
142 def self.for(form)
143 rsm_max = form.find(
144 "ns:set/ns:max",
145 ns: "http://jabber.org/protocol/rsm"
146 ).first
147 if rsm_max
148 new(rsm_max.content.to_i)
149 else
150 Default.new
151 end
152 end
153
154 def initialize(quantity)
155 @quantity = quantity
156 end
157
158 def iris_query
159 { quantity: @quantity }
160 end
161
162 # NOTE: Gajim sends back the whole list on submit, so big
163 # lists can cause issues
164 class Default
165 def iris_query
166 { quantity: 10 }
167 end
168 end
169 end
170 end
171
172 class Tn
173 attr_reader :tel
174
175 def initialize(full_number:, city:, state:, **)
176 @tel = "+1#{full_number}"
177 @locality = city
178 @region = state
179 end
180
181 def formatted_tel
182 @tel =~ /\A\+1(\d{3})(\d{3})(\d+)\Z/
183 "(#{$1}) #{$2}-#{$3}"
184 end
185
186 def option
187 op = Blather::Stanza::X::Field::Option.new(value: tel, label: to_s)
188 op << reference
189 op
190 end
191
192 def reference
193 Nokogiri::XML::Builder.new { |xml|
194 xml.reference(
195 xmlns: "urn:xmpp:reference:0",
196 begin: 0,
197 end: formatted_tel.length - 1,
198 type: "data",
199 uri: "tel:#{tel}"
200 )
201 }.doc.root
202 end
203
204 def to_s
205 "#{formatted_tel} (#{@locality}, #{@region})"
206 end
207 end
208
209 class Q
210 def self.register(regex, &block)
211 @queries ||= []
212 @queries << [regex, block]
213 end
214
215 def self.for(q, **kwa)
216 @queries.each do |(regex, block)|
217 match_data = (q =~ regex)
218 return block.call($1 || $&, *$~.to_a[2..-1], **kwa) if match_data
219 end
220
221 raise "Format not recognized: #{q}"
222 end
223
224 def initialize(q)
225 @q = q
226 end
227
228 def fallback
229 []
230 end
231
232 {
233 areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
234 npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
235 npaNxxx: [:NpaNxxx, /\A(?:[2-9][0-9]{2}){2}[0-9]\Z/],
236 zip: [:PostalCode, /\A\d{5}(?:-\d{4})?\Z/],
237 localVanity: [:LocalVanity, /\A~(.+)\Z/]
238 }.each do |k, args|
239 klass = const_set(
240 args[0],
241 Class.new(Q) {
242 define_method(:iris_query) do
243 { k => @q }
244 end
245 }
246 )
247
248 args[1..-1].each do |regex|
249 register(regex) { |q, **| klass.new(q) }
250 end
251 end
252
253 class State
254 Q.register(/\A[a-zA-Z]{2}\Z/, &method(:new))
255
256 STATE_MAP = {
257 "QC" => "PQ"
258 }.freeze
259
260 def initialize(state, **)
261 @state = STATE_MAP.fetch(state.upcase, state.upcase)
262 end
263
264 def fallback
265 []
266 end
267
268 def iris_query
269 { state: @state }
270 end
271
272 def to_s
273 @state
274 end
275 end
276
277 class CityState
278 Q.register(/\A([^,]+)\s*,\s*([a-zA-Z]{2})\Z/, &method(:new))
279
280 CITY_MAP = {
281 "ajax" => "Ajax-Pickering",
282 "kitchener" => "Kitchener-Waterloo",
283 "new york" => "New York City",
284 "pickering" => "Ajax-Pickering",
285 "sault ste marie" => "sault sainte marie",
286 "sault ste. marie" => "sault sainte marie",
287 "south durham" => "Durham",
288 "township of langley" => "Langley",
289 "waterloo" => "Kitchener-Waterloo",
290 "west durham" => "Durham"
291 }.freeze
292
293 def initialize(city, state, db: DB, memcache: MEMCACHE)
294 @city = CITY_MAP.fetch(city.downcase, city)
295 @state = State.new(state)
296 @db = db
297 @memcache = memcache
298 end
299
300 def fallback
301 LazyObject.new do
302 AreaCodeRepo.new(
303 db: @db,
304 geo_code_repo: GeoCodeRepo.new(memcache: @memcache)
305 ).find(to_s).sync.map { |area_code|
306 AreaCode.new(area_code)
307 }
308 end
309 end
310
311 def iris_query
312 @state.iris_query.merge(city: @city)
313 end
314
315 def to_s
316 "#{@city}, #{@state}"
317 end
318 end
319 end
320 end
321end