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,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("&", "&amp;").gsub('"', "&quot;").gsub("<", "&lt;").gsub(">", "&gt;")
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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What `Bundler.require` loads for `gem "vatio-sdk"`. The server API stays
4
+ # opt-in: `require "vatio/server"`.
5
+ require_relative "identity"
@@ -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