vatio-sdk 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +423 -0
- data/lib/vatio/identity/authentication.rb +67 -0
- data/lib/vatio/identity/config.rb +124 -0
- data/lib/vatio/identity/railtie.rb +15 -0
- data/lib/vatio/identity/version.rb +7 -0
- data/lib/vatio/identity.rb +168 -0
- data/lib/vatio/sdk.rb +5 -0
- data/lib/vatio/server/client.rb +142 -0
- data/lib/vatio/server/config.rb +75 -0
- data/lib/vatio/server/errors.rb +129 -0
- data/lib/vatio/server/message.rb +85 -0
- data/lib/vatio/server/send_message_job.rb +72 -0
- data/lib/vatio/server/template.rb +34 -0
- data/lib/vatio/server/testing.rb +322 -0
- data/lib/vatio/server/transport.rb +50 -0
- data/lib/vatio/server/webhook.rb +85 -0
- data/lib/vatio/server/webhook_receiver.rb +88 -0
- data/lib/vatio/server.rb +85 -0
- metadata +83 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vatio
|
|
4
|
+
module Server
|
|
5
|
+
# A WhatsApp template the key's number can send, from Vatio's mirror of
|
|
6
|
+
# Meta's: its body, how many `{{n}}` parameters that body takes, and the
|
|
7
|
+
# object `send_message` expects as `template:`.
|
|
8
|
+
#
|
|
9
|
+
# shipped = Vatio::Server.templates.find { |t| t.name == "order_shipped" }
|
|
10
|
+
# Vatio::Server.send_message(to: phone, brief: brief, template: shipped.with_params("Ana", "#1042"))
|
|
11
|
+
#
|
|
12
|
+
# Empty on a preview key: the preview number's templates are Vatio's.
|
|
13
|
+
Template = Struct.new(
|
|
14
|
+
:name, :language, :category, :status, :rejected_reason, :body, :parameter_count, :example,
|
|
15
|
+
keyword_init: true
|
|
16
|
+
) do
|
|
17
|
+
def self.from(hash) = Value.build(self, hash)
|
|
18
|
+
|
|
19
|
+
def approved? = status == "APPROVED"
|
|
20
|
+
|
|
21
|
+
# Strings, in body order. The count is checked here when it is known,
|
|
22
|
+
# because Meta only reports a wrong one by refusing the send.
|
|
23
|
+
def with_params(*params)
|
|
24
|
+
params = params.flatten.map(&:to_s)
|
|
25
|
+
if parameter_count && params.size != parameter_count
|
|
26
|
+
raise ArgumentError, "vatio server: template #{name} (#{language}) takes #{parameter_count} " \
|
|
27
|
+
"param#{"s" unless parameter_count == 1}, got #{params.size}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
{ name: name, language: language, params: params }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require_relative "../server"
|
|
7
|
+
|
|
8
|
+
module Vatio
|
|
9
|
+
module Server
|
|
10
|
+
# For your own test suite: no network, no key, and a record of what your
|
|
11
|
+
# code asked Vatio to send.
|
|
12
|
+
#
|
|
13
|
+
# require "vatio/server/testing"
|
|
14
|
+
#
|
|
15
|
+
# setup { Vatio::Server::Testing.fake! }
|
|
16
|
+
# teardown { Vatio::Server::Testing.real! }
|
|
17
|
+
#
|
|
18
|
+
# Order.ship!(order)
|
|
19
|
+
# sent = Vatio::Server::Testing.messages.last
|
|
20
|
+
# assert_equal "+56912345678", sent.to
|
|
21
|
+
# assert_equal "order-1042-shipped", sent.external_ref
|
|
22
|
+
#
|
|
23
|
+
# Vatio::Server::Testing.fail_next!(:outside_window)
|
|
24
|
+
# assert_raises(Vatio::Server::OutsideWindow) { Order.remind!(order) }
|
|
25
|
+
#
|
|
26
|
+
# The fake answers as the API does -- 202 and a queued message, 200 and
|
|
27
|
+
# the same message for an idempotency key it has seen, Conflict for one
|
|
28
|
+
# reused with a different request -- through the same parsing and error
|
|
29
|
+
# mapping as a real response, so what your code rescues is what it will
|
|
30
|
+
# rescue in production. It assumes the 24-hour window is open; set
|
|
31
|
+
# `window_open = false` and a message without a template raises
|
|
32
|
+
# OutsideWindow, as it would.
|
|
33
|
+
module Testing
|
|
34
|
+
# A request to send, as the fake received it. `subject` and `claims`
|
|
35
|
+
# are read back out of the identity token, unverified.
|
|
36
|
+
Recorded = Struct.new(
|
|
37
|
+
:id, :to, :brief, :text, :template, :external_ref, :idempotency_key, :identity, :subject, :claims, :mode,
|
|
38
|
+
keyword_init: true
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Status and code for the refusals `fail_next!` knows by name. Any
|
|
42
|
+
# other code is a 422, unless you pass `status:`.
|
|
43
|
+
FAILURES = {
|
|
44
|
+
"outside_window" => 422, "opted_out" => 409, "human_takeover" => 409, "handoff_pending" => 409,
|
|
45
|
+
"template_required" => 422, "template_not_found" => 422, "template_not_approved" => 422,
|
|
46
|
+
"template_params" => 422, "content_required" => 422, "brief_too_long" => 422, "text_too_long" => 422,
|
|
47
|
+
"invalid_recipient" => 422, "not_a_test_phone" => 422, "identity_not_configured" => 422,
|
|
48
|
+
"invalid_identity" => 422, "whatsapp_not_connected" => 409, "whatsapp_paused" => 409,
|
|
49
|
+
"idempotency_conflict" => 409, "identity_conflict" => 409, "invalid_key" => 401,
|
|
50
|
+
"workspace_forbidden" => 403, "not_found" => 404, "rate_limited" => 429, "unavailable" => 503
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# `fail_next!(:timeout)` and `fail_next!(:network_error)` raise in the
|
|
54
|
+
# transport, as a socket would, rather than answering at all.
|
|
55
|
+
NETWORK_FAILURES = %w[timeout network_error].freeze
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
attr_writer :templates, :window_open
|
|
59
|
+
|
|
60
|
+
# Swaps the transport for the in-memory one, and fills a key and a
|
|
61
|
+
# workspace if the suite has none, so no test needs a real `vsk_`.
|
|
62
|
+
# With a block, puts everything back afterwards.
|
|
63
|
+
def fake!
|
|
64
|
+
unless faking?
|
|
65
|
+
@previous = { transport: Server.instance_variable_get(:@transport),
|
|
66
|
+
server_key: Server.config.server_key,
|
|
67
|
+
workspace: Server.config.instance_variable_get(:@workspace) }
|
|
68
|
+
Server.config.server_key = "vsk_test_fake" if Server.config.server_key.to_s.strip.empty?
|
|
69
|
+
Server.config.workspace = "test" if Server.config.workspace.nil?
|
|
70
|
+
Server.transport = FakeTransport.new
|
|
71
|
+
end
|
|
72
|
+
reset!
|
|
73
|
+
return self unless block_given?
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
yield
|
|
77
|
+
ensure
|
|
78
|
+
real!
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def real!
|
|
83
|
+
return unless faking?
|
|
84
|
+
|
|
85
|
+
Server.transport = @previous[:transport]
|
|
86
|
+
Server.config.server_key = @previous[:server_key]
|
|
87
|
+
Server.config.workspace = @previous[:workspace]
|
|
88
|
+
@previous = nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def faking? = Server.instance_variable_get(:@transport).is_a?(FakeTransport)
|
|
92
|
+
|
|
93
|
+
# Forgets what was sent and any pending failure; templates and the
|
|
94
|
+
# window go back to their defaults.
|
|
95
|
+
def reset!
|
|
96
|
+
@templates = []
|
|
97
|
+
@window_open = true
|
|
98
|
+
fake.reset! if faking?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Each message the fake created, oldest first. A replay of an
|
|
102
|
+
# idempotency key creates none, as it would not on Vatio.
|
|
103
|
+
def messages = faking? ? fake.messages : []
|
|
104
|
+
|
|
105
|
+
# Every request, reads included: [method, path, body].
|
|
106
|
+
def requests = faking? ? fake.requests : []
|
|
107
|
+
|
|
108
|
+
# The next call answers with this refusal instead -- any call, so
|
|
109
|
+
# `fail_next!(:not_found)` before `message_status` works too.
|
|
110
|
+
def fail_next!(code, status: nil, message: nil, **details)
|
|
111
|
+
code = code.to_s
|
|
112
|
+
fake.fail_next = { code: code, status: status || FAILURES.fetch(code, 422),
|
|
113
|
+
message: message || "#{code} (Vatio::Server::Testing)", details: details }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# What `Vatio::Server.templates` answers: Hashes as the API sends
|
|
117
|
+
# them, or Templates.
|
|
118
|
+
def templates = @templates || []
|
|
119
|
+
|
|
120
|
+
def window_open? = @window_open != false
|
|
121
|
+
|
|
122
|
+
# A webhook request as Vatio would deliver it, for testing your
|
|
123
|
+
# receiver: post `body` with `headers` to its route.
|
|
124
|
+
#
|
|
125
|
+
# hook = Vatio::Server::Testing.webhook("message.replied", message: { id: 1, external_ref: "x" })
|
|
126
|
+
# post "/webhooks/vatio", params: hook.body, headers: hook.headers
|
|
127
|
+
#
|
|
128
|
+
# `data` as a Hash, or its keys as keywords, as above.
|
|
129
|
+
def webhook(type, data = {}, secret: Server.config.webhook_secret, id: "evt_#{rand(1_000_000)}", at: Time.now,
|
|
130
|
+
**fields)
|
|
131
|
+
event = { id: id, type: type, created_at: at.utc.iso8601, workspace: Server.config.workspace,
|
|
132
|
+
environment: "live", data: data.to_h.merge(fields) }
|
|
133
|
+
body = JSON.generate(event)
|
|
134
|
+
WebhookRequest.new(
|
|
135
|
+
body: body,
|
|
136
|
+
headers: { Webhook::HEADER => Webhook.sign(payload: body, secret: secret, at: at), "Content-Type" => "application/json" },
|
|
137
|
+
event: JSON.parse(body)
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def fake
|
|
144
|
+
Server.instance_variable_get(:@transport)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# What `webhook` builds: the body to post, its headers, and the event
|
|
149
|
+
# your handler should receive.
|
|
150
|
+
WebhookRequest = Struct.new(:body, :headers, :event, keyword_init: true)
|
|
151
|
+
|
|
152
|
+
# The transport the fake installs: the same `call` as the real one,
|
|
153
|
+
# answered from memory.
|
|
154
|
+
class FakeTransport
|
|
155
|
+
attr_accessor :fail_next
|
|
156
|
+
attr_reader :messages, :requests
|
|
157
|
+
|
|
158
|
+
def initialize
|
|
159
|
+
@lock = Mutex.new
|
|
160
|
+
reset!
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def reset!
|
|
164
|
+
@lock.synchronize do
|
|
165
|
+
@messages = []
|
|
166
|
+
@payloads = {}
|
|
167
|
+
@by_key = {}
|
|
168
|
+
@requests = []
|
|
169
|
+
@fail_next = nil
|
|
170
|
+
@next_id = 0
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def call(method:, url:, headers: {}, body: nil, **)
|
|
175
|
+
uri = URI.parse(url)
|
|
176
|
+
path = uri.path.sub(%r{\A#{Client::API_PATH}/[^/]+/}o, "")
|
|
177
|
+
query = URI.decode_www_form(uri.query.to_s).to_h
|
|
178
|
+
params = body ? JSON.parse(body) : {}
|
|
179
|
+
|
|
180
|
+
@lock.synchronize do
|
|
181
|
+
@requests << [ method.to_s.upcase, path, params ]
|
|
182
|
+
failure = @fail_next
|
|
183
|
+
@fail_next = nil
|
|
184
|
+
return answer_failure(failure) if failure
|
|
185
|
+
|
|
186
|
+
route(method.to_s.downcase, path, query, params, headers)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def route(method, path, query, params, headers)
|
|
193
|
+
shown = method == "get" && path[%r{\Amessages/(\d+)\z}, 1]
|
|
194
|
+
if method == "post" && path == "messages" then create(params, headers["Idempotency-Key"])
|
|
195
|
+
elsif method == "get" && path == "messages" then list(query)
|
|
196
|
+
elsif method == "get" && path == "templates" then json(200, { templates: Testing.templates.map(&:to_h) })
|
|
197
|
+
elsif shown && @payloads[shown.to_i] then json(200, @payloads[shown.to_i])
|
|
198
|
+
elsif shown then refusal(404, "not_found", "Resource not found")
|
|
199
|
+
else refusal(404, "not_found", "No such endpoint in Vatio::Server::Testing: #{method.upcase} #{path}")
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def create(params, key)
|
|
204
|
+
if key && (previous = @by_key[key])
|
|
205
|
+
return json(200, @payloads[previous[:id]], "idempotent-replayed" => "true") if previous[:params] == params
|
|
206
|
+
|
|
207
|
+
return refusal(409, "idempotency_conflict",
|
|
208
|
+
"Idempotency-Key #{key.inspect} was already used for a different message (#{previous[:id]}).",
|
|
209
|
+
message_id: previous[:id])
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
to = params["to"].to_s
|
|
213
|
+
return refusal(422, "invalid_recipient", "to must be a phone number in international format.") if to.gsub(/\D/, "").length < 8
|
|
214
|
+
if params["brief"].to_s.strip.empty? && params["text"].to_s.strip.empty?
|
|
215
|
+
return refusal(422, "content_required", "Send `text` or `brief`.")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
mode = if Testing.window_open? then params["text"] ? "text" : "agent"
|
|
219
|
+
elsif params["template"] then "template"
|
|
220
|
+
end
|
|
221
|
+
unless mode
|
|
222
|
+
return refusal(422, "outside_window", "The contact has not written in the last 24 hours.",
|
|
223
|
+
last_inbound_at: nil)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
claims = claims_of(params["identity"])
|
|
227
|
+
template = params["template"]&.slice("name", "language", "params")
|
|
228
|
+
text = (params["text"] if mode == "text")
|
|
229
|
+
template = (template if mode == "template")
|
|
230
|
+
|
|
231
|
+
id = (@next_id += 1)
|
|
232
|
+
payload = {
|
|
233
|
+
id: id, status: "queued", mode: mode, channel: "whatsapp", environment: "live",
|
|
234
|
+
to: "+#{to.gsub(/\D/, "")}", chat_id: id, external_ref: params["external_ref"], idempotency_key: key,
|
|
235
|
+
brief: params["brief"], text: text, template: template, content: nil, chat_message_id: nil,
|
|
236
|
+
delivery: nil, error: nil, sent_at: nil, replied_at: nil, created_at: Time.now.utc.iso8601
|
|
237
|
+
}
|
|
238
|
+
@payloads[id] = JSON.parse(JSON.generate(payload))
|
|
239
|
+
@by_key[key] = { id: id, params: params } if key
|
|
240
|
+
@messages << Recorded.new(
|
|
241
|
+
id: id, to: params["to"], brief: params["brief"], text: params["text"], template: params["template"],
|
|
242
|
+
external_ref: params["external_ref"], idempotency_key: key, identity: params["identity"],
|
|
243
|
+
subject: claims["sub"], claims: claims.reject { |name, _| Identity::REGISTERED_CLAIMS.include?(name) },
|
|
244
|
+
mode: mode
|
|
245
|
+
).freeze
|
|
246
|
+
json(202, payload)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def list(query)
|
|
250
|
+
items = @payloads.values.reverse
|
|
251
|
+
items = items.select { |item| item["external_ref"] == query["external_ref"] } if query["external_ref"]
|
|
252
|
+
items = items.select { |item| item["status"] == query["status"] } if query["status"]
|
|
253
|
+
items = items.select { |item| item["id"] < query["before"].to_i } if query["before"]
|
|
254
|
+
limit = (query["limit"] || 50).to_i.clamp(1, 50)
|
|
255
|
+
page = items.first(limit)
|
|
256
|
+
json(200, { messages: page, next_before: (page.last["id"] if page.size == limit && !page.empty?) })
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def answer_failure(failure)
|
|
260
|
+
if NETWORK_FAILURES.include?(failure[:code])
|
|
261
|
+
raise Unavailable.new("vatio server: #{failure[:message]}", code: failure[:code])
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
refusal(failure[:status], failure[:code], failure[:message], **failure[:details])
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def claims_of(token)
|
|
268
|
+
return {} if token.to_s.empty?
|
|
269
|
+
|
|
270
|
+
JWT.decode(token, nil, false).first
|
|
271
|
+
rescue JWT::DecodeError
|
|
272
|
+
{}
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def refusal(status, code, message, **details)
|
|
276
|
+
json(status, { error: { code: code, message: message, **details }, request_id: "req_test" },
|
|
277
|
+
"x-request-id" => "req_test")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def json(status, body, headers = {})
|
|
281
|
+
Response.new(status: status, headers: { "content-type" => "application/json" }.merge(headers),
|
|
282
|
+
body: JSON.generate(body))
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
if defined?(RSpec::Matchers)
|
|
290
|
+
# expect { Order.ship!(order) }.to have_sent_message(to: "+56912345678", external_ref: "order-1042-shipped")
|
|
291
|
+
# expect(Vatio::Server::Testing).to have_sent_message(to: "+56912345678")
|
|
292
|
+
#
|
|
293
|
+
# Every attribute but `to` is compared as given, RSpec matchers included
|
|
294
|
+
# (`brief: a_string_including("1042")`); `to` is compared by its digits,
|
|
295
|
+
# so "+56 9 1234 5678" matches "+56912345678".
|
|
296
|
+
RSpec::Matchers.define :have_sent_message do |to: nil, **expected|
|
|
297
|
+
supports_block_expectations
|
|
298
|
+
|
|
299
|
+
match do |actual|
|
|
300
|
+
before = Vatio::Server::Testing.messages.size
|
|
301
|
+
actual.call if actual.is_a?(Proc)
|
|
302
|
+
candidates = actual.is_a?(Proc) ? Vatio::Server::Testing.messages.drop(before) : Vatio::Server::Testing.messages
|
|
303
|
+
|
|
304
|
+
@found = candidates.select do |message|
|
|
305
|
+
(to.nil? || message.to.to_s.gsub(/\D/, "") == to.to_s.gsub(/\D/, "")) &&
|
|
306
|
+
expected.all? { |name, value| values_match?(value, message.public_send(name)) }
|
|
307
|
+
end
|
|
308
|
+
@candidates = candidates
|
|
309
|
+
!@found.empty?
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
failure_message do
|
|
313
|
+
wanted = { to: to, **expected }.compact
|
|
314
|
+
sent = @candidates.map { |message| message.to_h.slice(:to, *expected.keys) }
|
|
315
|
+
"expected a message matching #{wanted.inspect}, but #{sent.empty? ? "none was sent" : "sent: #{sent.inspect}"}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
failure_message_when_negated do
|
|
319
|
+
"expected no message matching #{{ to: to, **expected }.compact.inspect}, but #{@found.size} were sent"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Vatio
|
|
8
|
+
module Server
|
|
9
|
+
# What a transport answers: the status, the headers with downcased names,
|
|
10
|
+
# and the body as it came.
|
|
11
|
+
Response = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
# The one place the gem opens a socket, kept to an interface small enough
|
|
14
|
+
# that Vatio::Server::Testing swaps it for an in-memory one:
|
|
15
|
+
#
|
|
16
|
+
# call(method:, url:, headers:, body:) -> Response
|
|
17
|
+
#
|
|
18
|
+
# A connection per request. Messages are sent one at a time, a few a
|
|
19
|
+
# minute at most, and a fresh connection is one fewer thing to share
|
|
20
|
+
# between threads.
|
|
21
|
+
class Transport
|
|
22
|
+
# Everything that means "Vatio did not answer", as opposed to "Vatio
|
|
23
|
+
# said no". All of it is worth retrying.
|
|
24
|
+
NETWORK_ERRORS = [
|
|
25
|
+
Timeout::Error, IOError, EOFError, SocketError, SystemCallError, OpenSSL::SSL::SSLError
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
def call(method:, url:, headers: {}, body: nil, open_timeout: nil, read_timeout: nil)
|
|
29
|
+
uri = URI.parse(url)
|
|
30
|
+
request = Net::HTTP.const_get(method.to_s.capitalize).new(uri.request_uri)
|
|
31
|
+
headers.each { |name, value| request[name] = value }
|
|
32
|
+
request.body = body if body
|
|
33
|
+
|
|
34
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
35
|
+
http.use_ssl = uri.scheme == "https"
|
|
36
|
+
http.open_timeout = open_timeout || Config::DEFAULT_OPEN_TIMEOUT
|
|
37
|
+
http.read_timeout = read_timeout || Config::DEFAULT_READ_TIMEOUT
|
|
38
|
+
# A POST is never resent behind the caller's back: it is the
|
|
39
|
+
# idempotency key, not Net::HTTP, that makes a resend safe.
|
|
40
|
+
http.max_retries = 0 if http.respond_to?(:max_retries=)
|
|
41
|
+
|
|
42
|
+
response = http.start { |connection| connection.request(request) }
|
|
43
|
+
Response.new(status: response.code.to_i, headers: response.each_header.to_h, body: response.body.to_s)
|
|
44
|
+
rescue *NETWORK_ERRORS => e
|
|
45
|
+
raise Unavailable.new("vatio server: #{uri&.host} did not answer (#{e.class}: #{e.message})",
|
|
46
|
+
code: e.is_a?(Timeout::Error) ? "timeout" : "network_error")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Vatio
|
|
7
|
+
module Server
|
|
8
|
+
# Checks that a webhook came from Vatio, the way it was signed:
|
|
9
|
+
#
|
|
10
|
+
# Vatio-Signature: t=1696262400,v1=5257a8...
|
|
11
|
+
#
|
|
12
|
+
# `v1` is the hex HMAC-SHA256 of "#{t}.#{raw body}" under the endpoint's
|
|
13
|
+
# `whsec_` secret. The timestamp is inside what is signed, so a captured
|
|
14
|
+
# delivery cannot be replayed later with a fresh `t`, and one older than
|
|
15
|
+
# `tolerance` is refused.
|
|
16
|
+
#
|
|
17
|
+
# The raw body, byte for byte: a body parsed and serialized again is a
|
|
18
|
+
# different string, and its signature does not match. In Rails that is
|
|
19
|
+
# `request.raw_post`, never `params`.
|
|
20
|
+
module Webhook
|
|
21
|
+
HEADER = "Vatio-Signature"
|
|
22
|
+
TOLERANCE = 300
|
|
23
|
+
|
|
24
|
+
InvalidSignature = Class.new(StandardError)
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# The event, parsed -- `{"id", "type", "created_at", "workspace",
|
|
29
|
+
# "environment", "data"}` -- or InvalidSignature.
|
|
30
|
+
#
|
|
31
|
+
# Vatio delivers at least once: a delivery that timed out on your side
|
|
32
|
+
# is sent again, with the same `event["id"]`. Dedupe on it.
|
|
33
|
+
def verify!(payload:, signature:, secret:, tolerance: TOLERANCE, now: Time.now)
|
|
34
|
+
secret = secret.to_s
|
|
35
|
+
# An empty secret is a key anyone can sign with.
|
|
36
|
+
raise ConfigurationError, "vatio server: the webhook secret is blank -- see Vatio::Server.configure" if secret.strip.empty?
|
|
37
|
+
|
|
38
|
+
payload = payload.to_s
|
|
39
|
+
timestamp, digests = parse(signature)
|
|
40
|
+
raise InvalidSignature, "no #{HEADER} header, or not t=...,v1=..." if timestamp.nil? || digests.empty?
|
|
41
|
+
raise InvalidSignature, "#{HEADER} timestamp is outside the tolerance" if (now.to_i - timestamp).abs > tolerance.to_i
|
|
42
|
+
|
|
43
|
+
expected = digest(secret, timestamp, payload)
|
|
44
|
+
raise InvalidSignature, "#{HEADER} does not match the body" unless digests.any? { |candidate| secure_compare(candidate, expected) }
|
|
45
|
+
|
|
46
|
+
event = JSON.parse(payload)
|
|
47
|
+
raise InvalidSignature, "the body is not a JSON object" unless event.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
event
|
|
50
|
+
rescue JSON::ParserError
|
|
51
|
+
raise InvalidSignature, "the body is not JSON"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The header Vatio would send for this body, for your own tests of a
|
|
55
|
+
# receiver. Vatio::Server::Testing.webhook builds a whole request.
|
|
56
|
+
def sign(payload:, secret:, at: Time.now)
|
|
57
|
+
timestamp = at.to_i
|
|
58
|
+
"t=#{timestamp},v1=#{digest(secret.to_s, timestamp, payload.to_s)}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def digest(secret, timestamp, payload)
|
|
62
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Every `v1` is a candidate: a header may one day carry two, while a
|
|
66
|
+
# secret is being rotated.
|
|
67
|
+
def parse(header)
|
|
68
|
+
timestamp = nil
|
|
69
|
+
digests = []
|
|
70
|
+
header.to_s.split(",").each do |part|
|
|
71
|
+
name, value = part.strip.split("=", 2)
|
|
72
|
+
case name
|
|
73
|
+
when "t" then timestamp = Integer(value.to_s, exception: false)
|
|
74
|
+
when "v1" then digests << value.to_s
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
[ timestamp, digests ]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def secure_compare(left, right)
|
|
81
|
+
left.bytesize == right.bytesize && OpenSSL.fixed_length_secure_compare(left, right)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vatio
|
|
4
|
+
module Server
|
|
5
|
+
# A controller that receives Vatio's webhooks: it checks the signature
|
|
6
|
+
# on the raw body, answers 400 to anything that does not verify, and
|
|
7
|
+
# hands each verified event to the handler for its type.
|
|
8
|
+
#
|
|
9
|
+
# # config/routes.rb
|
|
10
|
+
# post "/webhooks/vatio", to: "vatio_webhooks#create"
|
|
11
|
+
#
|
|
12
|
+
# class VatioWebhooksController < ActionController::API
|
|
13
|
+
# include Vatio::Server::WebhookReceiver
|
|
14
|
+
#
|
|
15
|
+
# on_vatio_event "message.replied" do |event|
|
|
16
|
+
# message = Vatio::Server::Message.from(event["data"]["message"])
|
|
17
|
+
# Order.find_by(vatio_ref: message.external_ref)&.touch(:customer_answered_at)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# The secret is `Vatio::Server.config.webhook_secret` unless the
|
|
22
|
+
# controller defines `vatio_webhook_secret`. Override
|
|
23
|
+
# `handle_vatio_event(event)` instead of the macro to see every event.
|
|
24
|
+
#
|
|
25
|
+
# Vatio delivers at least once, so the same event can arrive twice:
|
|
26
|
+
# dedupe on `event["id"]`, which is the same on every retry. A handler
|
|
27
|
+
# that raises answers 500 and the delivery is retried later, which is
|
|
28
|
+
# what you want when your database was down, and why a handler should
|
|
29
|
+
# be safe to run twice.
|
|
30
|
+
module WebhookReceiver
|
|
31
|
+
def self.included(base)
|
|
32
|
+
base.extend(ClassMethods)
|
|
33
|
+
# Vatio has no session and no CSRF token: the signature is what
|
|
34
|
+
# authenticates this request.
|
|
35
|
+
base.skip_forgery_protection if base.respond_to?(:skip_forgery_protection)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
module ClassMethods
|
|
39
|
+
# One or more event types -- message.sent, message.failed,
|
|
40
|
+
# message.delivered, message.read, message.delivery_failed,
|
|
41
|
+
# message.replied, ping. The block runs on the controller instance.
|
|
42
|
+
def on_vatio_event(*types, &handler)
|
|
43
|
+
raise ArgumentError, "vatio server: on_vatio_event needs a block" unless handler
|
|
44
|
+
|
|
45
|
+
own = (@vatio_event_handlers ||= {})
|
|
46
|
+
types.flatten.each { |type| (own[type.to_s] ||= []) << handler }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Inherited handlers first, then this class's own.
|
|
50
|
+
def vatio_event_handlers
|
|
51
|
+
inherited = superclass.respond_to?(:vatio_event_handlers) ? superclass.vatio_event_handlers : {}
|
|
52
|
+
inherited.merge(@vatio_event_handlers || {}) { |_type, parent, child| parent + child }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def create
|
|
57
|
+
event = Webhook.verify!(
|
|
58
|
+
payload: request.raw_post,
|
|
59
|
+
signature: request.headers[Webhook::HEADER],
|
|
60
|
+
secret: vatio_webhook_secret,
|
|
61
|
+
tolerance: vatio_webhook_tolerance
|
|
62
|
+
)
|
|
63
|
+
rescue Webhook::InvalidSignature => e
|
|
64
|
+
logger&.warn("[Vatio::Server::WebhookReceiver] refused: #{e.message}")
|
|
65
|
+
head :bad_request
|
|
66
|
+
else
|
|
67
|
+
handle_vatio_event(event)
|
|
68
|
+
head :ok unless performed?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def handle_vatio_event(event)
|
|
74
|
+
self.class.vatio_event_handlers.fetch(event["type"].to_s, []).each do |handler|
|
|
75
|
+
instance_exec(event, &handler)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def vatio_webhook_secret
|
|
80
|
+
Server.config.webhook_secret
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def vatio_webhook_tolerance
|
|
84
|
+
Webhook::TOLERANCE
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/vatio/server.rb
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
require_relative "identity"
|
|
7
|
+
require_relative "server/config"
|
|
8
|
+
require_relative "server/errors"
|
|
9
|
+
require_relative "server/message"
|
|
10
|
+
require_relative "server/template"
|
|
11
|
+
require_relative "server/transport"
|
|
12
|
+
require_relative "server/client"
|
|
13
|
+
require_relative "server/webhook"
|
|
14
|
+
require_relative "server/webhook_receiver"
|
|
15
|
+
|
|
16
|
+
module Vatio
|
|
17
|
+
# The part of this gem that talks to Vatio, and only if you ask for it:
|
|
18
|
+
# `require "vatio/identity"` never loads it, so signing a token for the
|
|
19
|
+
# widget still makes no network call at all.
|
|
20
|
+
#
|
|
21
|
+
# Your backend calls the server API with a server key (`vsk_`) to have the
|
|
22
|
+
# agent write to someone first -- an order that shipped, an appointment
|
|
23
|
+
# tomorrow -- and hears back through a signed webhook.
|
|
24
|
+
#
|
|
25
|
+
# require "vatio/server"
|
|
26
|
+
#
|
|
27
|
+
# Vatio::Server.configure do |c|
|
|
28
|
+
# c.server_key = ENV["VATIO_SERVER_KEY"]
|
|
29
|
+
# c.workspace = "acme" # defaults to Vatio::Identity's audience
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# Vatio::Server.send_message(
|
|
33
|
+
# to: "+56912345678",
|
|
34
|
+
# brief: "Order #1042 shipped today with Starken, tracking 99812.",
|
|
35
|
+
# external_ref: "order-1042-shipped"
|
|
36
|
+
# )
|
|
37
|
+
module Server
|
|
38
|
+
# Long enough for the person to answer tomorrow and still be signed in
|
|
39
|
+
# when they do -- the token is what lets a private tool run on their
|
|
40
|
+
# reply -- and short enough that a conversation nobody came back to does
|
|
41
|
+
# not stay signed in for good.
|
|
42
|
+
IDENTITY_TTL = 48 * 3600
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
attr_writer :transport
|
|
46
|
+
|
|
47
|
+
def config
|
|
48
|
+
@config ||= Config.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def configure
|
|
52
|
+
yield config
|
|
53
|
+
config
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Net::HTTP unless swapped, which is what Vatio::Server::Testing does.
|
|
57
|
+
def transport
|
|
58
|
+
@transport ||= Transport.new
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A client for the configured key. Build your own with
|
|
62
|
+
# `Client.new(server_key:, workspace:)` to use two keys at once -- a
|
|
63
|
+
# preview key in staging next to the live one, say.
|
|
64
|
+
def client
|
|
65
|
+
Client.new(config)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def send_message(**options) = client.send_message(**options)
|
|
69
|
+
def message_status(id) = client.message_status(id)
|
|
70
|
+
def messages(**filters) = client.messages(**filters)
|
|
71
|
+
def templates = client.templates
|
|
72
|
+
def identity_for(**options) = client.identity_for(**options)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Only where there is a queue to put it on. Autoloaded rather than required:
|
|
78
|
+
# in a Rails app ActiveJob::Base itself loads lazily, and the job is often
|
|
79
|
+
# first named from a controller, before any job class has been. Without
|
|
80
|
+
# ActiveJob loaded yet, `on_load` defines it when it is.
|
|
81
|
+
if defined?(ActiveJob)
|
|
82
|
+
Vatio::Server.autoload(:SendMessageJob, File.expand_path("server/send_message_job", __dir__))
|
|
83
|
+
elsif defined?(ActiveSupport) && ActiveSupport.respond_to?(:on_load)
|
|
84
|
+
ActiveSupport.on_load(:active_job) { require_relative "server/send_message_job" }
|
|
85
|
+
end
|