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.
@@ -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
@@ -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