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,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
require_relative "identity/version"
|
|
7
|
+
require_relative "identity/config"
|
|
8
|
+
|
|
9
|
+
module Vatio
|
|
10
|
+
# Signs the one credential Vatio accepts, and verifies it on the way back.
|
|
11
|
+
#
|
|
12
|
+
# Vatio holds only the public half of the key, so the most it can do is
|
|
13
|
+
# check: it cannot mint a token for one of your users. That is the property
|
|
14
|
+
# that makes it reasonable for a platform to be verifying identity at all --
|
|
15
|
+
# and it is also why every way this can go wrong lives on this side of the
|
|
16
|
+
# line, in your application, which is what this library is for.
|
|
17
|
+
#
|
|
18
|
+
# Vatio::Identity.configure do |c|
|
|
19
|
+
# c.audience = "acme" # your workspace slug
|
|
20
|
+
# c.private_key = ENV["VATIO_IDENTITY_PRIVATE_KEY"]
|
|
21
|
+
# end
|
|
22
|
+
module Identity
|
|
23
|
+
Error = Class.new(StandardError)
|
|
24
|
+
ConfigurationError = Class.new(Error)
|
|
25
|
+
|
|
26
|
+
# The same leeway Vatio verifies with. Clocks disagree by seconds; a minute
|
|
27
|
+
# covers the drift between two servers and is far too short to matter to
|
|
28
|
+
# anyone holding an expired token.
|
|
29
|
+
LEEWAY = 60
|
|
30
|
+
|
|
31
|
+
# `exp` because a token with no expiry is a permanent credential sitting in
|
|
32
|
+
# page source. `aud` because a signature proves who signed, not what for:
|
|
33
|
+
# without it any other JWT you sign with this key -- a password reset, a
|
|
34
|
+
# download link -- is accepted as an identity, since it also has a `sub`.
|
|
35
|
+
REQUIRED_CLAIMS = %w[sub exp aud].freeze
|
|
36
|
+
|
|
37
|
+
# Carried by every JWT and meaningful to the protocol rather than to you,
|
|
38
|
+
# so they are stripped back out of `claims` on the way in.
|
|
39
|
+
REGISTERED_CLAIMS = %w[iss sub aud exp nbf iat jti].freeze
|
|
40
|
+
|
|
41
|
+
Principal = Struct.new(:subject, :claims, :token, keyword_init: true)
|
|
42
|
+
|
|
43
|
+
SIGN_IN_HOST = "https://vatio.ai"
|
|
44
|
+
SIGN_IN_CODE = /\A[A-Za-z0-9_-]{16,64}\z/
|
|
45
|
+
|
|
46
|
+
# A week rather than the widget's hour. The widget gets a fresh token on
|
|
47
|
+
# every page load; a WhatsApp contact keeps this one until it expires and
|
|
48
|
+
# then has to sign in again, so an hour would mean signing in for every
|
|
49
|
+
# conversation. Pass `expires_in:` to choose otherwise.
|
|
50
|
+
SIGN_IN_EXPIRES_IN = 7 * 24 * 3600
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
def config
|
|
54
|
+
@config ||= Config.new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def configure
|
|
58
|
+
yield config
|
|
59
|
+
config
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A signed token for one of your users, ready to hand to the widget.
|
|
63
|
+
#
|
|
64
|
+
# Registered claims are merged last on purpose: a caller passing
|
|
65
|
+
# `aud:` or `exp:` in `claims` is either confused or being creative with
|
|
66
|
+
# someone else's session, and neither is worth honouring.
|
|
67
|
+
#
|
|
68
|
+
# `audience` is for Vatio::Server, which signs for the workspace its
|
|
69
|
+
# server key names; everyone else leaves it to the configured slug.
|
|
70
|
+
# `config` is for an app serving several workspaces: that workspace's
|
|
71
|
+
# Config, in place of the global one.
|
|
72
|
+
def token_for(subject:, claims: {}, expires_in: nil, audience: nil, config: self.config)
|
|
73
|
+
subject = subject.to_s.strip
|
|
74
|
+
raise ArgumentError, "vatio identity: subject cannot be blank" if subject.empty?
|
|
75
|
+
|
|
76
|
+
now = Time.now.to_i
|
|
77
|
+
payload = stringify(claims).merge(
|
|
78
|
+
"sub" => subject,
|
|
79
|
+
"aud" => audience.to_s.strip.empty? ? config.audience! : audience.to_s.strip,
|
|
80
|
+
"iat" => now,
|
|
81
|
+
"exp" => now + (expires_in || config.expires_in).to_i
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
JWT.encode(payload, config.private_key, config.algorithm)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The other half, for your own API: a private tool forwards the same JWT
|
|
88
|
+
# as `$auth.token`, and the endpoint behind it has to check the signature
|
|
89
|
+
# itself rather than trust the caller. Returns nil for anything that does
|
|
90
|
+
# not verify -- expired, wrong key, wrong audience, absent.
|
|
91
|
+
#
|
|
92
|
+
# Nil is not an error. It means "not signed in", which on the web is the
|
|
93
|
+
# normal state of a visitor reading a marketing page.
|
|
94
|
+
def verify(raw, config: self.config)
|
|
95
|
+
raw = raw.to_s.strip
|
|
96
|
+
return nil if raw.empty?
|
|
97
|
+
|
|
98
|
+
payload, = JWT.decode(
|
|
99
|
+
raw, config.public_key, true,
|
|
100
|
+
# Pinned, never read from the token's header.
|
|
101
|
+
algorithms: [ config.algorithm ],
|
|
102
|
+
required_claims: REQUIRED_CLAIMS.dup,
|
|
103
|
+
verify_expiration: true,
|
|
104
|
+
leeway: LEEWAY,
|
|
105
|
+
aud: config.audience!,
|
|
106
|
+
verify_aud: true
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
Principal.new(
|
|
110
|
+
subject: payload["sub"].to_s,
|
|
111
|
+
claims: payload.reject { |k, _| REGISTERED_CLAIMS.include?(k) },
|
|
112
|
+
token: raw
|
|
113
|
+
)
|
|
114
|
+
rescue JWT::DecodeError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The script tag, with the token on it only when somebody is signed in.
|
|
119
|
+
#
|
|
120
|
+
# Beware of caching. Rails renders this per request, so it is safe by
|
|
121
|
+
# default -- but the moment this fragment lands in a page cache, a CDN,
|
|
122
|
+
# or `caches_action`, you are serving one customer's identity to the
|
|
123
|
+
# next visitor. If anything caches this page, render it without a token
|
|
124
|
+
# and call `window.VatioWidget.identify(token)` from an uncached
|
|
125
|
+
# endpoint instead.
|
|
126
|
+
def widget_tag(workspace:, token:, subject: nil, claims: {}, config: self.config, **attrs)
|
|
127
|
+
visitor = subject && token_for(subject: subject, claims: claims, config: config)
|
|
128
|
+
pairs = {
|
|
129
|
+
"src" => "https://cdn.vatio.ai/v1/widget.js",
|
|
130
|
+
"async" => "async",
|
|
131
|
+
"data-workspace" => workspace,
|
|
132
|
+
"data-token" => token,
|
|
133
|
+
"data-visitor-token" => visitor
|
|
134
|
+
}.merge(attrs.transform_keys { |k| "data-#{k.to_s.tr("_", "-")}" }).compact
|
|
135
|
+
|
|
136
|
+
"<script #{pairs.map { |k, v| %(#{k}="#{escape(v)}") }.join(" ")}></script>"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Where to send someone who tapped "Sign in" on WhatsApp or Instagram,
|
|
140
|
+
# once they are signed in to your site. The page at `auth.sign_in_url`
|
|
141
|
+
# receives `?code=...`; redirect to this with that code and the same
|
|
142
|
+
# subject and claims you would put on the widget.
|
|
143
|
+
#
|
|
144
|
+
# The host is fixed on purpose. A url taken from the request would let
|
|
145
|
+
# anyone who can craft a link have your server sign a token for whoever
|
|
146
|
+
# opens it and hand it to them.
|
|
147
|
+
def sign_in_redirect_url(code:, subject:, claims: {}, expires_in: SIGN_IN_EXPIRES_IN, config: self.config)
|
|
148
|
+
code = code.to_s
|
|
149
|
+
raise ArgumentError, "vatio identity: not a Vatio sign-in code" unless code.match?(SIGN_IN_CODE)
|
|
150
|
+
|
|
151
|
+
token = token_for(subject: subject, claims: claims, expires_in: expires_in, config: config)
|
|
152
|
+
"#{SIGN_IN_HOST}/connect/#{code}?token=#{token}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def stringify(claims)
|
|
158
|
+
Hash(claims).each_with_object({}) { |(k, v), out| out[k.to_s] = v }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def escape(value)
|
|
162
|
+
value.to_s.gsub("&", "&").gsub('"', """).gsub("<", "<").gsub(">", ">")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
require_relative "identity/railtie" if defined?(Rails::Railtie)
|
data/lib/vatio/sdk.rb
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Vatio
|
|
6
|
+
module Server
|
|
7
|
+
# The server API, one method per endpoint. `Vatio::Server.send_message`
|
|
8
|
+
# and friends use one built from `Vatio::Server.config`; build your own
|
|
9
|
+
# for a second key:
|
|
10
|
+
#
|
|
11
|
+
# staging = Vatio::Server::Client.new(server_key: ENV["VATIO_PREVIEW_KEY"], workspace: "acme")
|
|
12
|
+
class Client
|
|
13
|
+
API_PATH = "/api/server/v1"
|
|
14
|
+
|
|
15
|
+
attr_reader :config
|
|
16
|
+
|
|
17
|
+
def initialize(config = nil, transport: nil, **options)
|
|
18
|
+
@config = config || Config.new(**options)
|
|
19
|
+
@transport = transport
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Has the agent write to `to` first. Returns a Message, `queued`: it
|
|
23
|
+
# is recorded and on its way, and the send itself happens a moment
|
|
24
|
+
# later -- read it back with `message_status` or hear about it on your
|
|
25
|
+
# webhook.
|
|
26
|
+
#
|
|
27
|
+
# brief: what the agent should write about, and the facts to use.
|
|
28
|
+
# text: your own words, sent as is.
|
|
29
|
+
# template: { name:, language:, params: [] }, used only when the 24-hour
|
|
30
|
+
# window is closed -- send it too to always reach the person.
|
|
31
|
+
#
|
|
32
|
+
# idempotency_key defaults to external_ref. Same key, same request:
|
|
33
|
+
# Vatio answers with the message the first one made and sends nothing
|
|
34
|
+
# (`replayed?`). Same key, different request: Conflict. So give each
|
|
35
|
+
# message its own ref -- "order-1042-shipped", not "order-1042".
|
|
36
|
+
#
|
|
37
|
+
# subject: signs an identity for that user (aud: the workspace, exp:
|
|
38
|
+
# now + identity_ttl), so a private tool can run when they answer. The
|
|
39
|
+
# exp has to cover how long they may take to: past it, they are
|
|
40
|
+
# answered as a stranger. Or pass a token you signed as `identity:`.
|
|
41
|
+
def send_message(to:, brief: nil, text: nil, template: nil, external_ref: nil, idempotency_key: nil,
|
|
42
|
+
subject: nil, claims: {}, identity_ttl: IDENTITY_TTL, identity: nil)
|
|
43
|
+
raise ArgumentError, "vatio server: pass subject: or identity:, not both" if subject && identity
|
|
44
|
+
|
|
45
|
+
identity ||= identity_for(subject: subject, claims: claims, ttl: identity_ttl) if subject
|
|
46
|
+
key = present(idempotency_key) || present(external_ref)
|
|
47
|
+
body = {
|
|
48
|
+
to: to.to_s,
|
|
49
|
+
brief: brief,
|
|
50
|
+
text: text,
|
|
51
|
+
template: template && normalize_template(template),
|
|
52
|
+
external_ref: external_ref,
|
|
53
|
+
identity: identity
|
|
54
|
+
}.compact
|
|
55
|
+
|
|
56
|
+
response = request(:post, "messages", body: body, headers: key ? { "Idempotency-Key" => key } : {})
|
|
57
|
+
Message.from(response.body, replayed: response.headers["idempotent-replayed"] == "true")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def message_status(id)
|
|
61
|
+
Message.from(request(:get, "messages/#{escape(id)}").body)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Newest first, up to 50 a page. `status` is queued, sent or failed;
|
|
65
|
+
# `external_ref` finds the messages for one of your own records.
|
|
66
|
+
def messages(external_ref: nil, status: nil, limit: nil, before: nil)
|
|
67
|
+
query = { external_ref: external_ref, status: status, limit: limit, before: before }.compact
|
|
68
|
+
MessageList.from(request(:get, "messages", query: query).body)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def templates
|
|
72
|
+
Array(request(:get, "templates").body["templates"]).map { |item| Template.from(item) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The identity token a message carries, signed with this client's
|
|
76
|
+
# `identity` config (Vatio::Identity's unless given) for its workspace.
|
|
77
|
+
# Sign once and pass it as `identity:` if you retry a call yourself: a
|
|
78
|
+
# token signed again is a different request to the idempotency check,
|
|
79
|
+
# and answers Conflict.
|
|
80
|
+
def identity_for(subject:, claims: {}, ttl: IDENTITY_TTL)
|
|
81
|
+
Identity.token_for(subject: subject, claims: claims, expires_in: ttl.to_i,
|
|
82
|
+
audience: config.workspace!, config: config.identity)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def transport
|
|
88
|
+
@transport || Server.transport
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def request(method, path, query: nil, body: nil, headers: {})
|
|
92
|
+
url = "#{config.base_url!}#{API_PATH}/#{escape(config.workspace!)}/#{path}"
|
|
93
|
+
url += "?#{URI.encode_www_form(query)}" if query && !query.empty?
|
|
94
|
+
|
|
95
|
+
response = transport.call(
|
|
96
|
+
method: method,
|
|
97
|
+
url: url,
|
|
98
|
+
headers: {
|
|
99
|
+
"Authorization" => "Bearer #{config.server_key!}",
|
|
100
|
+
"Accept" => "application/json",
|
|
101
|
+
"Content-Type" => "application/json",
|
|
102
|
+
"User-Agent" => "vatio-sdk-ruby/#{Identity::VERSION}"
|
|
103
|
+
}.merge(headers),
|
|
104
|
+
body: body && JSON.generate(body),
|
|
105
|
+
open_timeout: config.open_timeout,
|
|
106
|
+
read_timeout: config.read_timeout
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
parsed = parse(response.body)
|
|
110
|
+
raise Error.from_response(response.status, parsed, response.headers) unless (200..299).cover?(response.status)
|
|
111
|
+
|
|
112
|
+
unless parsed.is_a?(Hash)
|
|
113
|
+
raise Unavailable.new("vatio server: HTTP #{response.status} with a body that is not JSON",
|
|
114
|
+
code: "invalid_response", status: response.status, request_id: response.headers["x-request-id"])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
Response.new(status: response.status, headers: response.headers, body: parsed)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def parse(body)
|
|
121
|
+
return nil if body.nil? || body.empty?
|
|
122
|
+
|
|
123
|
+
JSON.parse(body)
|
|
124
|
+
rescue JSON::ParserError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Accepts a Hash with symbol or string keys, or anything with `to_h`.
|
|
129
|
+
def normalize_template(template)
|
|
130
|
+
hash = template.to_h.transform_keys(&:to_s)
|
|
131
|
+
{ name: hash["name"], language: hash["language"], params: Array(hash["params"]).map(&:to_s) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def present(value)
|
|
135
|
+
value = value.to_s.strip
|
|
136
|
+
value.empty? ? nil : value
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def escape(value) = URI.encode_www_form_component(value.to_s)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vatio
|
|
4
|
+
module Server
|
|
5
|
+
ConfigurationError = Class.new(StandardError)
|
|
6
|
+
|
|
7
|
+
# The server key and the workspace it belongs to, and where to reach it.
|
|
8
|
+
#
|
|
9
|
+
# The key already names its workspace and environment; the slug goes in
|
|
10
|
+
# the path anyway, and Vatio refuses a request whose two disagree. That is
|
|
11
|
+
# what keeps a key pasted into the wrong service from quietly writing to
|
|
12
|
+
# another workspace's customers.
|
|
13
|
+
class Config
|
|
14
|
+
DEFAULT_BASE_URL = "https://vatio.ai"
|
|
15
|
+
|
|
16
|
+
# A send is recorded and queued, never performed in the request, so it
|
|
17
|
+
# answers in milliseconds; anything slower than this is not coming.
|
|
18
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
19
|
+
DEFAULT_READ_TIMEOUT = 15
|
|
20
|
+
|
|
21
|
+
# `webhook_secret` is the endpoint's `whsec_`, read by WebhookReceiver
|
|
22
|
+
# unless the controller defines `vatio_webhook_secret` itself.
|
|
23
|
+
attr_accessor :server_key, :base_url, :open_timeout, :read_timeout, :webhook_secret
|
|
24
|
+
attr_writer :workspace, :identity
|
|
25
|
+
|
|
26
|
+
def initialize(server_key: nil, workspace: nil, base_url: DEFAULT_BASE_URL,
|
|
27
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, webhook_secret: nil,
|
|
28
|
+
identity: nil)
|
|
29
|
+
@server_key = server_key
|
|
30
|
+
@workspace = workspace
|
|
31
|
+
@identity = identity
|
|
32
|
+
@base_url = base_url
|
|
33
|
+
@open_timeout = open_timeout
|
|
34
|
+
@read_timeout = read_timeout
|
|
35
|
+
@webhook_secret = webhook_secret
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The Vatio::Identity::Config that signs `subject:` identities. The
|
|
39
|
+
# global one unless this client is for another workspace's key.
|
|
40
|
+
def identity
|
|
41
|
+
@identity || Identity.config
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The slug, falling back to the identity's audience: it is the same
|
|
45
|
+
# workspace, and saying it twice is a chance to say it differently.
|
|
46
|
+
def workspace
|
|
47
|
+
value = @workspace.to_s.strip
|
|
48
|
+
value = identity.audience.to_s.strip if value.empty?
|
|
49
|
+
value.empty? ? nil : value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def workspace!
|
|
53
|
+
workspace || raise(ConfigurationError, missing("workspace (your Vatio workspace slug)"))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Never logged, never put in an error message.
|
|
57
|
+
def server_key!
|
|
58
|
+
value = server_key.to_s.strip
|
|
59
|
+
return value unless value.empty?
|
|
60
|
+
|
|
61
|
+
raise ConfigurationError, missing("server_key (a vsk_ key from `vatio keys create`)")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def base_url!
|
|
65
|
+
base_url.to_s.strip.delete_suffix("/").then { |url| url.empty? ? DEFAULT_BASE_URL : url }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def missing(name)
|
|
71
|
+
"vatio server: #{name} is not configured -- see Vatio::Server.configure"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vatio
|
|
4
|
+
module Server
|
|
5
|
+
# Every refusal from the server API, with the `code` it was sent with.
|
|
6
|
+
#
|
|
7
|
+
# The subclasses are the decisions a caller actually makes: send a
|
|
8
|
+
# template instead (OutsideWindow), leave this person alone (OptedOut,
|
|
9
|
+
# HumanAnswering), fix the request (TemplateError, InvalidRequest), fix
|
|
10
|
+
# the key (Unauthorized), or try again later (RateLimited, Unavailable).
|
|
11
|
+
# Only the last two are worth retrying, and `retryable?` says so.
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
attr_reader :code, :status, :details, :request_id
|
|
14
|
+
|
|
15
|
+
# `headers` is the response's, for the subclasses that read one.
|
|
16
|
+
def initialize(message = nil, code: nil, status: nil, details: {}, request_id: nil, headers: nil)
|
|
17
|
+
super(message || code || "vatio server error")
|
|
18
|
+
@code = code&.to_s
|
|
19
|
+
@status = status
|
|
20
|
+
@details = details || {}
|
|
21
|
+
@request_id = request_id
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def retryable? = false
|
|
25
|
+
|
|
26
|
+
# The class a refusal maps to: by code first, since the code is the
|
|
27
|
+
# contract, and by status for codes this version does not know yet.
|
|
28
|
+
def self.class_for(status, code)
|
|
29
|
+
case code.to_s
|
|
30
|
+
when "outside_window" then OutsideWindow
|
|
31
|
+
when "opted_out" then OptedOut
|
|
32
|
+
when "human_takeover", "handoff_pending" then HumanAnswering
|
|
33
|
+
when /\Atemplate_/ then TemplateError
|
|
34
|
+
when "rate_limited" then RateLimited
|
|
35
|
+
when "invalid_key", "workspace_forbidden" then Unauthorized
|
|
36
|
+
when "not_found" then NotFound
|
|
37
|
+
else
|
|
38
|
+
case status.to_i
|
|
39
|
+
when 401, 403 then Unauthorized
|
|
40
|
+
when 404 then NotFound
|
|
41
|
+
when 409 then Conflict
|
|
42
|
+
when 400, 422 then InvalidRequest
|
|
43
|
+
when 429 then RateLimited
|
|
44
|
+
when 408, 500..599 then Unavailable
|
|
45
|
+
else Error
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# From a response the API answered with: `{error: {code, message,
|
|
51
|
+
# ...details}, request_id}`. A body that is not that -- a proxy's HTML
|
|
52
|
+
# 502 -- still becomes an error of the right class, by its status.
|
|
53
|
+
def self.from_response(status, body, headers = {})
|
|
54
|
+
payload = body.is_a?(Hash) ? body : {}
|
|
55
|
+
error = payload["error"].is_a?(Hash) ? payload["error"] : {}
|
|
56
|
+
code = error["code"] || "http_#{status}"
|
|
57
|
+
message = error["message"] || "Vatio answered HTTP #{status}"
|
|
58
|
+
details = error.reject { |key, _| %w[code message].include?(key) }
|
|
59
|
+
|
|
60
|
+
class_for(status, code).new(
|
|
61
|
+
message, code: code, status: status, details: details,
|
|
62
|
+
request_id: payload["request_id"] || headers["x-request-id"], headers: headers
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The contact has not written in the last 24 hours, so WhatsApp only
|
|
68
|
+
# delivers an approved template, and none was sent. Only you can decide
|
|
69
|
+
# whether a template is the right thing to send instead.
|
|
70
|
+
class OutsideWindow < Error
|
|
71
|
+
def last_inbound_at
|
|
72
|
+
value = details["last_inbound_at"]
|
|
73
|
+
value && Time.iso8601(value.to_s)
|
|
74
|
+
rescue ArgumentError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The contact asked not to be written to from this number.
|
|
80
|
+
OptedOut = Class.new(Error)
|
|
81
|
+
|
|
82
|
+
# A person is answering this conversation from the inbox
|
|
83
|
+
# (`human_takeover`), or it is waiting for one (`handoff_pending`).
|
|
84
|
+
# Writing over them is exactly what this refusal is for.
|
|
85
|
+
HumanAnswering = Class.new(Error)
|
|
86
|
+
|
|
87
|
+
# The template cannot be sent as given: not found (often created in Meta a
|
|
88
|
+
# minute ago -- Vatio is syncing, retry shortly), not approved, or the
|
|
89
|
+
# wrong number of params.
|
|
90
|
+
TemplateError = Class.new(Error)
|
|
91
|
+
|
|
92
|
+
# Any other 422: an invalid number, no `brief` or `text`, an identity
|
|
93
|
+
# that does not verify, a preview key writing to a number that is not a
|
|
94
|
+
# test phone.
|
|
95
|
+
InvalidRequest = Class.new(Error)
|
|
96
|
+
|
|
97
|
+
# The key is missing, wrong, revoked, or belongs to another workspace.
|
|
98
|
+
Unauthorized = Class.new(Error)
|
|
99
|
+
|
|
100
|
+
# A message id this key's workspace and environment do not have.
|
|
101
|
+
NotFound = Class.new(Error)
|
|
102
|
+
|
|
103
|
+
# Any other 409: the idempotency key was already used for a different
|
|
104
|
+
# message (`details["message_id"]` names it), the conversation is
|
|
105
|
+
# signed in as someone else, or the WhatsApp number is not connected or
|
|
106
|
+
# not live.
|
|
107
|
+
Conflict = Class.new(Error)
|
|
108
|
+
|
|
109
|
+
# More than 60 messages a minute on one key.
|
|
110
|
+
class RateLimited < Error
|
|
111
|
+
attr_reader :retry_after
|
|
112
|
+
|
|
113
|
+
# Seconds, when Vatio says; nil when it does not.
|
|
114
|
+
def initialize(message = nil, headers: nil, **options)
|
|
115
|
+
super
|
|
116
|
+
@retry_after = Integer((headers || {})["retry-after"].to_s, exception: false)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def retryable? = true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Vatio did not answer, or answered 5xx: a timeout, a refused connection,
|
|
123
|
+
# a deploy. Retry with the same idempotency key and a request that did
|
|
124
|
+
# get through is answered with the message it made, not a second one.
|
|
125
|
+
class Unavailable < Error
|
|
126
|
+
def retryable? = true
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vatio
|
|
4
|
+
module Server
|
|
5
|
+
# Shared by the values below: the API's JSON, read into a frozen struct.
|
|
6
|
+
# Keys this version does not know are dropped rather than refused, so a
|
|
7
|
+
# field Vatio adds tomorrow does not break today's gem.
|
|
8
|
+
module Value
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def time(value)
|
|
12
|
+
value && Time.iso8601(value.to_s)
|
|
13
|
+
rescue ArgumentError
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build(klass, hash, **extra)
|
|
18
|
+
hash = (hash || {}).transform_keys(&:to_s)
|
|
19
|
+
attrs = klass.members.to_h { |name| [ name, hash[name.to_s] ] }
|
|
20
|
+
klass.new(**attrs, **extra).freeze
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A message your backend sent, as Vatio reports it -- the same shape on
|
|
25
|
+
# the response, on `message_status`, and in every webhook's
|
|
26
|
+
# `data["message"]`:
|
|
27
|
+
#
|
|
28
|
+
# Vatio::Server::Message.from(event["data"]["message"])
|
|
29
|
+
#
|
|
30
|
+
# `status` is the message's own state: queued (recorded, not sent yet),
|
|
31
|
+
# sent, or failed (with `error["code"]`). `content` is what reached the
|
|
32
|
+
# phone -- the agent's words, your text as sent, or the template rendered
|
|
33
|
+
# -- and `chat_message_id` its row in the conversation, once it is sent.
|
|
34
|
+
# `delivery` is Meta's word on it -- sent, delivered, read or failed --
|
|
35
|
+
# once there is one. Nested hashes (`template`, `delivery`, `error`) keep
|
|
36
|
+
# the API's string keys.
|
|
37
|
+
Message = Struct.new(
|
|
38
|
+
:id, :status, :mode, :channel, :environment, :to, :chat_id, :external_ref, :idempotency_key,
|
|
39
|
+
:brief, :text, :template, :content, :chat_message_id, :delivery, :error, :sent_at, :replied_at,
|
|
40
|
+
:created_at, :replayed,
|
|
41
|
+
keyword_init: true
|
|
42
|
+
) do
|
|
43
|
+
def self.from(hash, replayed: false)
|
|
44
|
+
hash = (hash || {}).transform_keys(&:to_s)
|
|
45
|
+
times = %w[sent_at replied_at created_at].to_h { |key| [ key, Value.time(hash[key]) ] }
|
|
46
|
+
Value.build(self, hash.merge(times).except("replayed"), replayed: replayed)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# True when the request reused an idempotency key: this is the message
|
|
50
|
+
# the first request made, as it stands now, and nothing new was sent.
|
|
51
|
+
def replayed? = replayed == true
|
|
52
|
+
|
|
53
|
+
def queued? = status == "queued"
|
|
54
|
+
def sent? = status == "sent"
|
|
55
|
+
def failed? = status == "failed"
|
|
56
|
+
def replied? = !replied_at.nil?
|
|
57
|
+
|
|
58
|
+
def error_code = error && error["code"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# One page of `messages`, newest first. Pass `next_before` back as
|
|
62
|
+
# `before:` for the next one; it is nil on the last page.
|
|
63
|
+
MessageList = Struct.new(:messages, :next_before, keyword_init: true) do
|
|
64
|
+
include Enumerable
|
|
65
|
+
|
|
66
|
+
def self.from(hash)
|
|
67
|
+
hash = (hash || {}).transform_keys(&:to_s)
|
|
68
|
+
new(
|
|
69
|
+
messages: Array(hash["messages"]).map { |item| Message.from(item) }.freeze,
|
|
70
|
+
next_before: hash["next_before"]
|
|
71
|
+
).freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def each(&block) = messages.each(&block)
|
|
75
|
+
def next_page? = !next_before.nil?
|
|
76
|
+
|
|
77
|
+
# The page's messages, not the struct's members, which is what Struct
|
|
78
|
+
# would answer for these ahead of Enumerable.
|
|
79
|
+
def to_a = messages.dup
|
|
80
|
+
alias_method :entries, :to_a
|
|
81
|
+
def size = messages.size
|
|
82
|
+
alias_method :length, :size
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Vatio
|
|
7
|
+
module Server
|
|
8
|
+
# `Vatio::Server.send_message` on your queue, with the same keywords:
|
|
9
|
+
#
|
|
10
|
+
# Vatio::Server::SendMessageJob.perform_later(
|
|
11
|
+
# to: order.phone, brief: "Order #{order.number} shipped...",
|
|
12
|
+
# external_ref: "order-#{order.id}-shipped", subject: order.user_id
|
|
13
|
+
# )
|
|
14
|
+
#
|
|
15
|
+
# Retried when Vatio is rate limiting or unreachable, never when it said
|
|
16
|
+
# no: a closed window or an opted-out contact is the same answer on the
|
|
17
|
+
# tenth try. Those are logged and discarded; subclass and `discard_on` the
|
|
18
|
+
# one you want to act on -- a later declaration wins:
|
|
19
|
+
#
|
|
20
|
+
# class ShippedMessageJob < Vatio::Server::SendMessageJob
|
|
21
|
+
# discard_on(Vatio::Server::OutsideWindow) { |job, error| ... }
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# A retry is safe because the request carries an idempotency key: yours,
|
|
25
|
+
# your external_ref, or one made here when the job is enqueued. And an
|
|
26
|
+
# identity for `subject:` is signed here too, once, rather than on every
|
|
27
|
+
# attempt -- a token signed again is a different request to the
|
|
28
|
+
# idempotency check, which would answer Conflict instead of the message
|
|
29
|
+
# the first attempt made. That token sits in your queue until the job
|
|
30
|
+
# runs, and its exp starts at enqueue: a job scheduled far ahead needs an
|
|
31
|
+
# identity_ttl that covers the wait too.
|
|
32
|
+
class SendMessageJob < ActiveJob::Base
|
|
33
|
+
queue_as :default
|
|
34
|
+
|
|
35
|
+
# Declared first so the retries below, declared later, take precedence.
|
|
36
|
+
discard_on Vatio::Server::Error do |job, error|
|
|
37
|
+
job.logger&.warn(
|
|
38
|
+
"[Vatio::Server::SendMessageJob] not sent: #{error.code} #{error.message} " \
|
|
39
|
+
"(status #{error.status.inspect}, request #{error.request_id.inspect})"
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# About 3s, 18s, 1.5m, 4m, 10m... -- past a rate limit's minute and past
|
|
44
|
+
# most deploys, and then it fails where your queue shows failed jobs.
|
|
45
|
+
retry_on Vatio::Server::RateLimited, Vatio::Server::Unavailable,
|
|
46
|
+
wait: ->(executions) { (executions**4) + 2 }, attempts: 10
|
|
47
|
+
|
|
48
|
+
def self.prepare(arguments)
|
|
49
|
+
options = arguments.first
|
|
50
|
+
return arguments unless arguments.size == 1 && options.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
options = options.transform_keys(&:to_sym)
|
|
53
|
+
subject = options.delete(:subject)
|
|
54
|
+
claims = options.delete(:claims) || {}
|
|
55
|
+
ttl = options.delete(:identity_ttl) || IDENTITY_TTL
|
|
56
|
+
options[:identity] ||= Server.identity_for(subject: subject, claims: claims, ttl: ttl) if subject
|
|
57
|
+
options[:idempotency_key] ||= options[:external_ref] || "job-#{SecureRandom.uuid}"
|
|
58
|
+
|
|
59
|
+
[ Hash.ruby2_keywords_hash(options) ]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize(*arguments)
|
|
63
|
+
super(*self.class.prepare(arguments))
|
|
64
|
+
end
|
|
65
|
+
ruby2_keywords(:initialize)
|
|
66
|
+
|
|
67
|
+
def perform(**options)
|
|
68
|
+
Server.send_message(**options)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|