vatio-sdk 0.6.0 → 0.7.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3ca193521e55a9d1c5c6918c5f7100f632effa64168fa4ccd8bc1ba27918357
4
- data.tar.gz: 71f39cb25a704a68b4cd47d0cd45fb7ca7a0162a879a1c281b4a47d86f2d911f
3
+ metadata.gz: f58bd6f7a713426a74eb89e6393bcd5b678d02abc6e29cc5e9e8286a1bc6366b
4
+ data.tar.gz: 36b34dfc45e5f65e74f24f14d74c54833a55ef11f8339396c3af7e0277db8b7b
5
5
  SHA512:
6
- metadata.gz: 8755693383c6f26e929c9dc0be96de067fdbaeed3d2d5dff227b9eed9fe034cfab244d17fa0f8b5baa0464c9849efffcb8b390039c672b4aeafdf87d6129f004
7
- data.tar.gz: a5f349288b444d346dcb2d951e7a5305aef998f2564120306b40ea39c8a5b74dc9bd123e9e897de36df7dcc8517db30c05c196fa9fe7ee113915c0c4a6d5d0e6
6
+ metadata.gz: 313a84c0a4b58cf9234f99acd6136bd7729bd2afc68a250341c63e63e5c9c85dcf5bd1712037763b6eaf1e3b34e468a67d3e5d36cc96430fb929e13382bdd784
7
+ data.tar.gz: 994c112d1aa656138ccd9b6855b52c45422610addaee8b1a2fe47e3bb97c1f10c295da6e396c4273e77851a4e4a4b1c9aa3e2b4630be1a90994296379ba49633
data/README.md CHANGED
@@ -8,8 +8,13 @@ That is the property that makes it reasonable for a platform to be verifying
8
8
  identity at all — and it is also why everything dangerous about the arrangement
9
9
  sits on *your* side of the line. This gem is that side, done once.
10
10
 
11
+ Building your app with Claude Code, Cursor or Codex? Start with
12
+ `npx @vatio-ai/skills` and `/vatio-init` in its project. When the agent needs to
13
+ know who your users are and your backend is Rails, your coding agent can set
14
+ this gem up for it. See [vatio.ai](https://vatio.ai).
15
+
11
16
  ```ruby
12
- gem "vatio-sdk", "~> 0.6"
17
+ gem "vatio-sdk", "~> 0.7"
13
18
  ```
14
19
 
15
20
  On [RubyGems](https://rubygems.org/gems/vatio-sdk) from 0.6.0. Until then it was
@@ -186,14 +191,14 @@ separate require, so an app that only signs tokens never loads it:
186
191
  require "vatio/server"
187
192
 
188
193
  Vatio::Server.configure do |c|
189
- c.server_key = ENV.fetch("VATIO_SERVER_KEY") # vsk_..., from `vatio keys create`
194
+ c.server_key = ENV.fetch("VATIO_SERVER_KEY") # vsk_..., from `vatio keys create secret`
190
195
  c.workspace = "acme" # defaults to Vatio::Identity's audience
191
196
  c.webhook_secret = ENV["VATIO_WEBHOOK_SECRET"] # whsec_..., for the receiver below
192
197
  end
193
198
  ```
194
199
 
195
- A server key belongs to one workspace and one environment, and a preview key
196
- can only write to a test phone verified under Test → WhatsApp in the console. Keep
200
+ A secret key belongs to one workspace and one environment, and a preview key
201
+ can only write to a test phone verified in the inbox's Try the agent panel (WhatsApp). Keep
197
202
  it on your server: it can message your customers. Net::HTTP does the talking
198
203
  (`base_url`, `open_timeout` and `read_timeout` are there if you need them);
199
204
  there is no other dependency.
@@ -290,7 +295,7 @@ end
290
295
  | `TemplateError` | `template_not_found` (often created in Meta a minute ago: Vatio is syncing, retry shortly), `template_not_approved`, `template_params`, `template_required` |
291
296
  | `InvalidRequest` | Any other 422: `invalid_recipient`, `content_required`, `invalid_identity`, `not_a_test_phone`… |
292
297
  | `Unauthorized` | 401 / 403: the key is wrong, revoked, or another workspace's |
293
- | `Conflict` | Any other 409: `idempotency_conflict` (`details["message_id"]`), `identity_conflict`, `whatsapp_not_connected`, `whatsapp_paused` |
298
+ | `Conflict` | Any other 409: `idempotency_conflict` (`details["message_id"]`), `identity_conflict`, `whatsapp_not_connected`, `whatsapp_paused`, `whatsapp_number_unhealthy` (Meta refuses every send from the number; `details["issue"]` says why and the fix) |
294
299
  | `NotFound` | `message_status` with an id this key cannot see |
295
300
  | `RateLimited` | 429, more than 60 a minute on one key |
296
301
  | `Unavailable` | 5xx, timeouts, refused connections |
@@ -330,7 +335,9 @@ Vatio POSTs `message.sent`, `message.failed`, `message.delivered`,
330
335
  `message.read`, `message.delivery_failed` and `message.replied` to your
331
336
  endpoint, signed with its `whsec_` secret. `data["message"]` is the message
332
337
  as `message_status` returns it, and `message.replied` adds `data["reply"]`
333
- (`chat_message_id`, `content`, `at`):
338
+ (`chat_message_id`, `content`, `at`). `feedback.submitted` is a visitor's
339
+ rating of a conversation, with `data["feedback"]`, `data["chat"]` and
340
+ `data["contact"]` ([payload](https://vatio.ai/docs/api/webhooks#ratings)):
334
341
 
335
342
  ```ruby
336
343
  # config/routes.rb
@@ -400,6 +407,76 @@ expect { Order.ship!(order) }.to have_sent_message(to: order.phone, brief: a_str
400
407
 
401
408
  See [Messages](https://vatio.ai/docs/api/messages) for the API itself.
402
409
 
410
+ ## Async tools
411
+
412
+ A tool declared `async:` in its `tools/*.yml` is one whose work takes longer
413
+ than a reply can wait: Vatio calls your endpoint with a job, your endpoint
414
+ answers 202 at once, and your worker posts the result when it has it. The
415
+ agent tells the person it is on it, knows the job is running on every turn,
416
+ and gives them the result in the same conversation, on any channel
417
+ ([Long-running tools](https://vatio.ai/docs/guides/ai-agent-long-running-tools)).
418
+ It is part of `require "vatio/server"`, and needs no server key: the job's
419
+ callback URL is signed and names that one job.
420
+
421
+ ```ruby
422
+ class Vatio::MatchesController < ApplicationController
423
+ include Vatio::Server::AsyncTool
424
+ before_action :check_tool_key # the tool's own Authorization, as for any tool
425
+
426
+ def create
427
+ MatchTherapistsJob.perform_later(vatio_job, reason: params[:reason], area: params[:area])
428
+ accept_vatio_job(message: "Finding matches; this usually takes about two minutes.")
429
+ end
430
+ end
431
+
432
+ class MatchTherapistsJob < ApplicationJob
433
+ def perform(vatio_job, reason:, area:)
434
+ vatio_job.run do |job|
435
+ job.progress("Reviewing 12 therapists in your area", notify: true)
436
+ therapists = Therapists.match(reason, area)
437
+ { result: "ok", message: "#{therapists.size} found", data: { therapists: therapists.map(&:to_h) } }
438
+ end
439
+ end
440
+ end
441
+ ```
442
+
443
+ - **`vatio_job`** reads the job from the request (`X-Vatio-Job-Id`,
444
+ `X-Vatio-Callback-Url`), or is nil when the call carries none: answer as any
445
+ tool then. A callback URL on any host but Vatio's raises
446
+ `InvalidRequest`, so a forged request cannot send your worker's results
447
+ elsewhere. **`accept_vatio_job`** answers 202; its `message` reaches the
448
+ agent.
449
+ - **The job is an ActiveJob argument** as it is. For another queue, store
450
+ `job.to_h` and rebuild it with `Vatio::Server::Job.from(hash)`. Your queue
451
+ stores the callback URL: treat it as a secret (`inspect` leaves it out).
452
+ - **`run`** posts what the block returns as the result: the body your
453
+ endpoint would have answered had it been fast, read through the tool's
454
+ `respond:`. If the block raises, the job is reported failed, so the
455
+ person hears now rather than at the timeout, and the error is raised on
456
+ for your queue. If the job is over while it runs (it timed out, or the
457
+ person called it off) it stops and returns nil.
458
+ - **By hand:** `progress(text, notify:)`, `complete(message:, data:)`,
459
+ `failed(message, error_key:)` and `respond(hash)`. Each returns the job's
460
+ `status` and whether the result was a `duplicate?`. Vatio unreachable or
461
+ rate limiting is retried three times in the call, over about 20 seconds,
462
+ rather than by your queue, which would redo the work. `JobClosed` means
463
+ the job is over: stop.
464
+
465
+ In tests, `Vatio::Server::Testing.job` builds a job to pass to your code,
466
+ `job_headers(job)` the headers for a request test of the endpoint, and
467
+ `callbacks` is what your code posted:
468
+
469
+ ```ruby
470
+ setup { Vatio::Server::Testing.fake! }
471
+
472
+ test "matches are reported" do
473
+ MatchTherapistsJob.perform_now(Vatio::Server::Testing.job, reason: "anxiety", area: "Ñuñoa")
474
+ result = Vatio::Server::Testing.callbacks.last
475
+ assert result.result?
476
+ assert_equal "ok", result.body["result"]
477
+ end
478
+ ```
479
+
403
480
  ## What it does not do
404
481
 
405
482
  Rotate keys, or cache tokens. Verification is repeated on every gate by
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Vatio
4
4
  module Identity
5
- VERSION = "0.6.0"
5
+ VERSION = "0.7.1"
6
6
  end
7
7
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vatio
4
+ module Server
5
+ # For the controller behind a tool declared `async:`: the job Vatio sent
6
+ # with the call, and the 202 that says it was accepted.
7
+ #
8
+ # class Vatio::MatchesController < ApplicationController
9
+ # include Vatio::Server::AsyncTool
10
+ #
11
+ # def create
12
+ # return render(json: match_now) unless vatio_job # not async: answer as any tool
13
+ #
14
+ # MatchTherapistsJob.perform_later(vatio_job, reason: params[:reason], area: params[:area])
15
+ # accept_vatio_job(message: "Finding matches; this usually takes about two minutes.")
16
+ # end
17
+ # end
18
+ #
19
+ # Check the tool's own credential as you would for any tool: the job
20
+ # headers say where to post, not who is calling.
21
+ module AsyncTool
22
+ private
23
+
24
+ # The Vatio::Server::Job this request carries, or nil when it carries
25
+ # none.
26
+ def vatio_job
27
+ return @vatio_job if defined?(@vatio_job)
28
+
29
+ @vatio_job = Job.from_headers(request.headers)
30
+ end
31
+
32
+ # 202: the agent is told the job started and the person that it is on
33
+ # its way. `message` reaches the agent, so say how long it usually
34
+ # takes.
35
+ def accept_vatio_job(message: nil)
36
+ render json: { message: message }.compact, status: :accepted
37
+ end
38
+ end
39
+ end
40
+ end
@@ -24,10 +24,21 @@ module Vatio
24
24
  # later -- read it back with `message_status` or hear about it on your
25
25
  # webhook.
26
26
  #
27
+ # to: a phone number, or on Instagram the person's @username or
28
+ # Instagram-scoped id.
29
+ # channel: "whatsapp" (the default) or "instagram". An Instagram DM
30
+ # only reaches someone who wrote in the last 24 hours, and
31
+ # takes no template.
32
+ # comment_id: an Instagram comment on your posts (an `instagram.comment`
33
+ # webhook, or `instagram_comments`): a private reply, the one
34
+ # DM Instagram lets you send whoever wrote it, within 7 days.
35
+ # Needs no `to` or `channel`.
27
36
  # brief: what the agent should write about, and the facts to use.
28
37
  # text: your own words, sent as is.
29
38
  # template: { name:, language:, params: [] }, used only when the 24-hour
30
39
  # window is closed -- send it too to always reach the person.
40
+ # An authentication template takes `code:` instead of params,
41
+ # with no brief or text, and is sent whatever the window.
31
42
  #
32
43
  # idempotency_key defaults to external_ref. Same key, same request:
33
44
  # Vatio answers with the message the first one made and sends nothing
@@ -38,14 +49,16 @@ module Vatio
38
49
  # now + identity_ttl), so a private tool can run when they answer. The
39
50
  # exp has to cover how long they may take to: past it, they are
40
51
  # 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)
52
+ def send_message(to: nil, channel: nil, comment_id: nil, brief: nil, text: nil, template: nil, external_ref: nil,
53
+ idempotency_key: nil, subject: nil, claims: {}, identity_ttl: IDENTITY_TTL, identity: nil)
43
54
  raise ArgumentError, "vatio server: pass subject: or identity:, not both" if subject && identity
44
55
 
45
56
  identity ||= identity_for(subject: subject, claims: claims, ttl: identity_ttl) if subject
46
57
  key = present(idempotency_key) || present(external_ref)
47
58
  body = {
48
- to: to.to_s,
59
+ to: to&.to_s,
60
+ channel: channel&.to_s,
61
+ comment_id: comment_id&.to_s,
49
62
  brief: brief,
50
63
  text: text,
51
64
  template: template && normalize_template(template),
@@ -68,6 +81,13 @@ module Vatio
68
81
  MessageList.from(request(:get, "messages", query: query).body)
69
82
  end
70
83
 
84
+ # Comments on your Instagram posts, newest first, up to 50 a page.
85
+ # `replyable: true` keeps the ones still waiting for a private reply.
86
+ def instagram_comments(replyable: nil, media_id: nil, limit: nil, before: nil)
87
+ query = { replyable: replyable, media_id: media_id, limit: limit, before: before }.compact
88
+ InstagramCommentList.from(request(:get, "instagram/comments", query: query).body)
89
+ end
90
+
71
91
  def templates
72
92
  Array(request(:get, "templates").body["templates"]).map { |item| Template.from(item) }
73
93
  end
@@ -128,7 +148,8 @@ module Vatio
128
148
  # Accepts a Hash with symbol or string keys, or anything with `to_h`.
129
149
  def normalize_template(template)
130
150
  hash = template.to_h.transform_keys(&:to_s)
131
- { name: hash["name"], language: hash["language"], params: Array(hash["params"]).map(&:to_s) }
151
+ { name: hash["name"], language: hash["language"], params: Array(hash["params"]).map(&:to_s),
152
+ code: hash["code"]&.to_s }.compact
132
153
  end
133
154
 
134
155
  def present(value)
@@ -34,6 +34,8 @@ module Vatio
34
34
  when "rate_limited" then RateLimited
35
35
  when "invalid_key", "workspace_forbidden" then Unauthorized
36
36
  when "not_found" then NotFound
37
+ when "job_closed", "unknown_job" then JobClosed
38
+ when "invalid_callback_url", "invalid_progress", "invalid_body", "result_too_large" then InvalidRequest
37
39
  else
38
40
  case status.to_i
39
41
  when 401, 403 then Unauthorized
@@ -100,6 +102,12 @@ module Vatio
100
102
  # A message id this key's workspace and environment do not have.
101
103
  NotFound = Class.new(Error)
102
104
 
105
+ # A job (Vatio::Server::Job) that is over for the conversation: it timed
106
+ # out, the person called it off, or its callback URL expired a day after
107
+ # its timeout. Nothing posted to it reaches anyone, so stop working on
108
+ # it; `Job#run` does.
109
+ JobClosed = Class.new(Error)
110
+
103
111
  # Any other 409: the idempotency key was already used for a different
104
112
  # message (`details["message_id"]` names it), the conversation is
105
113
  # signed in as someone else, or the WhatsApp number is not connected or
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "uri"
6
+
7
+ module Vatio
8
+ module Server
9
+ # A job an async tool started: a tool declared `async:` in its tools/*.yml
10
+ # is called with a job id and the URL to post the result to, and its
11
+ # endpoint answers 202 and does the work later. This is that job, on your
12
+ # side: read it in the endpoint, put it on your queue, and report back.
13
+ #
14
+ # class Vatio::MatchesController < ApplicationController
15
+ # include Vatio::Server::AsyncTool
16
+ #
17
+ # def create
18
+ # MatchTherapistsJob.perform_later(vatio_job, reason: params[:reason], area: params[:area])
19
+ # accept_vatio_job(message: "Finding matches; this usually takes about two minutes.")
20
+ # end
21
+ # end
22
+ #
23
+ # class MatchTherapistsJob < ApplicationJob
24
+ # def perform(vatio_job, reason:, area:)
25
+ # vatio_job.run do |job|
26
+ # job.progress("Reviewing 12 therapists in your area", notify: true)
27
+ # { result: "ok", message: "2 found", data: { therapists: find_matches(reason, area) } }
28
+ # end
29
+ # end
30
+ # end
31
+ #
32
+ # What a job posts is what your endpoint would have answered had it been
33
+ # fast: Vatio reads it through the tool's `respond:`, writes it into the
34
+ # conversation as the tool's answer and has the agent give it to the
35
+ # person. No server key is needed: the callback URL is signed and names
36
+ # this one job. Treat it as a secret -- `inspect` leaves it out, but
37
+ # your queue stores it.
38
+ class Job
39
+ HEADER_ID = "X-Vatio-Job-Id"
40
+ HEADER_CALLBACK_URL = "X-Vatio-Callback-Url"
41
+ HEADER_EXPIRES_AT = "X-Vatio-Job-Expires-At"
42
+
43
+ CALLBACK_PATH = "/api/jobs/v1/"
44
+
45
+ # Seconds between the attempts to post one callback when Vatio is
46
+ # unreachable or rate limiting. Retried here rather than by your queue,
47
+ # which would redo the work to post it again.
48
+ RETRY_WAITS = [ 1, 4, 15 ].freeze
49
+
50
+ # The answer to a callback: the job's status after it ("pending" after
51
+ # progress, "completed" or "failed" after the result), and whether this
52
+ # result had already been taken.
53
+ State = Struct.new(:job_id, :status, :duplicate, keyword_init: true) do
54
+ def duplicate? = duplicate == true
55
+ end
56
+
57
+ class << self
58
+ # How it waits between attempts. Vatio::Server::Testing waits for
59
+ # nothing.
60
+ attr_writer :sleeper
61
+
62
+ def sleeper = @sleeper || ->(seconds) { sleep(seconds) }
63
+
64
+ # The job a request carries, or nil when it carries none: the tool is
65
+ # not declared `async:`, or the call is not from Vatio. Takes
66
+ # Rails' `request.headers`, a Rack env or a Hash of header names.
67
+ #
68
+ # A callback URL on any host but Vatio's (`base_url`) is refused: a
69
+ # request that could choose where your worker posts a customer's
70
+ # data must not get to.
71
+ def from_headers(headers, config: Server.config)
72
+ id = header(headers, HEADER_ID)
73
+ url = header(headers, HEADER_CALLBACK_URL)
74
+ return nil if blank?(id) || blank?(url)
75
+
76
+ new(id: id, callback_url: url, expires_at: header(headers, HEADER_EXPIRES_AT), config: config)
77
+ end
78
+
79
+ # The inverse of `to_h`: for a queue that stores Hashes (Sidekiq, a
80
+ # database row). ActiveJob takes the Job itself.
81
+ def from(hash, config: Server.config)
82
+ hash = hash.to_h.transform_keys(&:to_s)
83
+ new(id: hash["id"], callback_url: hash["callback_url"], expires_at: hash["expires_at"], config: config)
84
+ end
85
+
86
+ private
87
+
88
+ def header(headers, name)
89
+ value = [ name, name.downcase, "HTTP_#{name.upcase.tr("-", "_")}" ].lazy.map { |key| headers[key] }
90
+ .find { |candidate| !blank?(candidate) }
91
+ value&.to_s&.strip
92
+ end
93
+
94
+ def blank?(value) = value.nil? || value.to_s.strip.empty?
95
+ end
96
+
97
+ attr_reader :id, :callback_url, :expires_at
98
+
99
+ def initialize(id:, callback_url:, expires_at: nil, config: Server.config)
100
+ @id = id.to_s
101
+ @callback_url = callback_url.to_s
102
+ @expires_at = parse_time(expires_at)
103
+ @config = config
104
+ check_callback_url!
105
+ end
106
+
107
+ # How far along it is. Kept for when the person asks how it is going;
108
+ # with `notify: true` the agent tells them now. For jobs that take
109
+ # minutes, not for every step.
110
+ def progress(text, notify: false)
111
+ post({ progress: text.to_s, notify: notify })
112
+ end
113
+
114
+ # The result: `respond:` maps `data` (or whatever else you send) as it
115
+ # maps your endpoint's answer, and `message` is relayed when the tool
116
+ # declares none.
117
+ def complete(message: nil, data: nil, **fields)
118
+ respond({ result: "ok", message: message, data: data }.compact.merge(fields))
119
+ end
120
+
121
+ # It could not be done. The agent tells the person, from `message`.
122
+ def failed(message, error_key: nil, **fields)
123
+ respond({ result: "error", message: message.to_s, error_key: error_key }.compact.merge(fields))
124
+ end
125
+
126
+ # The result as your endpoint would have answered it, exactly.
127
+ def respond(body)
128
+ raise ArgumentError, "vatio job: the result must be a Hash, got #{body.class}" unless body.is_a?(Hash)
129
+
130
+ post(body)
131
+ end
132
+
133
+ FAILURE_MESSAGE = "It could not be finished this time."
134
+
135
+ # Runs the block and posts what it returns (a Hash, as `respond`) as
136
+ # the result. If it raises, the job is reported failed with
137
+ # `failure_message` -- the person hears it now rather than when the
138
+ # job times out -- and the error is raised on, for your queue and your
139
+ # error tracker. If Vatio says the job is over (timed out, or the
140
+ # person called it off) while it runs, it stops there and returns nil.
141
+ def run(failure_message: FAILURE_MESSAGE)
142
+ result = yield(self)
143
+ respond(result)
144
+ rescue JobClosed
145
+ nil
146
+ rescue StandardError => e
147
+ begin
148
+ failed(failure_message)
149
+ rescue Error
150
+ nil
151
+ end
152
+ raise e
153
+ end
154
+
155
+ def expired?
156
+ !expires_at.nil? && Time.now >= expires_at
157
+ end
158
+
159
+ def to_h
160
+ { "id" => id, "callback_url" => callback_url, "expires_at" => expires_at&.utc&.iso8601 }
161
+ end
162
+
163
+ def ==(other)
164
+ other.is_a?(Job) && other.id == id && other.callback_url == callback_url
165
+ end
166
+ alias eql? ==
167
+
168
+ def hash = [ id, callback_url ].hash
169
+
170
+ # Without the callback URL: it is the job's credential.
171
+ def inspect
172
+ "#<#{self.class.name} #{id}#{" expires_at=#{expires_at.utc.iso8601}" if expires_at}>"
173
+ end
174
+ alias to_s inspect
175
+
176
+ private
177
+
178
+ def post(body)
179
+ attempt = 0
180
+ begin
181
+ attempt += 1
182
+ deliver(body)
183
+ rescue RateLimited, Unavailable
184
+ raise if attempt > RETRY_WAITS.size
185
+
186
+ self.class.sleeper.call(RETRY_WAITS[attempt - 1])
187
+ retry
188
+ end
189
+ end
190
+
191
+ def deliver(body)
192
+ response = Server.transport.call(
193
+ method: :post, url: callback_url, body: JSON.generate(body),
194
+ headers: { "Accept" => "application/json", "Content-Type" => "application/json",
195
+ "User-Agent" => "vatio-sdk-ruby/#{Identity::VERSION}" },
196
+ open_timeout: @config.open_timeout, read_timeout: @config.read_timeout
197
+ )
198
+ parsed = parse(response.body)
199
+ raise Error.from_response(response.status, parsed, response.headers) unless (200..299).cover?(response.status)
200
+
201
+ parsed = {} unless parsed.is_a?(Hash)
202
+ State.new(job_id: parsed["job_id"] || id, status: parsed["status"], duplicate: parsed["duplicate"] == true)
203
+ end
204
+
205
+ def parse(body)
206
+ return nil if body.nil? || body.empty?
207
+
208
+ JSON.parse(body)
209
+ rescue JSON::ParserError
210
+ nil
211
+ end
212
+
213
+ def parse_time(value)
214
+ return value if value.is_a?(Time)
215
+ return nil if value.nil? || value.to_s.strip.empty?
216
+
217
+ Time.iso8601(value.to_s)
218
+ rescue ArgumentError
219
+ nil
220
+ end
221
+
222
+ def check_callback_url!
223
+ uri = URI.parse(callback_url)
224
+ base = URI.parse(@config.base_url!)
225
+ same_host = uri.host&.downcase == base.host&.downcase && uri.scheme == base.scheme && uri.port == base.port
226
+ return if same_host && uri.path.start_with?(CALLBACK_PATH)
227
+
228
+ raise InvalidRequest.new("vatio job: the callback URL is not Vatio's (#{base.scheme}://#{base.host})",
229
+ code: "invalid_callback_url")
230
+ rescue URI::InvalidURIError
231
+ raise InvalidRequest.new("vatio job: the callback URL is not a URL", code: "invalid_callback_url")
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Vatio
6
+ module Server
7
+ # Lets a Vatio::Server::Job be an ActiveJob argument:
8
+ # `perform_later(vatio_job, ...)` stores its Hash and `perform` gets the
9
+ # Job back.
10
+ class JobSerializer < ActiveJob::Serializers::ObjectSerializer
11
+ def serialize(job) = super(job.to_h)
12
+
13
+ def deserialize(hash) = Job.from(hash)
14
+
15
+ def klass = Job
16
+ end
17
+ end
18
+ end
19
+
20
+ ActiveJob::Serializers.add_serializers(Vatio::Server::JobSerializer)
@@ -35,7 +35,7 @@ module Vatio
35
35
  # once there is one. Nested hashes (`template`, `delivery`, `error`) keep
36
36
  # the API's string keys.
37
37
  Message = Struct.new(
38
- :id, :status, :mode, :channel, :environment, :to, :chat_id, :external_ref, :idempotency_key,
38
+ :id, :status, :mode, :channel, :environment, :to, :comment_id, :chat_id, :external_ref, :idempotency_key,
39
39
  :brief, :text, :template, :content, :chat_message_id, :delivery, :error, :sent_at, :replied_at,
40
40
  :created_at, :replayed,
41
41
  keyword_init: true
@@ -58,6 +58,38 @@ module Vatio
58
58
  def error_code = error && error["code"]
59
59
  end
60
60
 
61
+ # A comment on your Instagram posts, which a private reply answers:
62
+ # `send_message(comment_id: comment.id, brief: ...)`. `from` is
63
+ # `{"id", "username"}`, `media` `{"id", "product_type"}`. `reply_until` is
64
+ # when Instagram stops taking the reply; `replied_message_id` the message
65
+ # that took it, once one did.
66
+ InstagramComment = Struct.new(
67
+ :id, :text, :from, :media, :parent_id, :live, :created_at, :reply_until, :replied_message_id,
68
+ keyword_init: true
69
+ ) do
70
+ def self.from(hash)
71
+ hash = (hash || {}).transform_keys(&:to_s)
72
+ times = %w[created_at reply_until].to_h { |key| [ key, Value.time(hash[key]) ] }
73
+ Value.build(self, hash.merge(times))
74
+ end
75
+
76
+ def username = from && from["username"]
77
+ def replied? = !replied_message_id.nil?
78
+ def replyable?(now: Time.now) = !replied? && !reply_until.nil? && now < reply_until
79
+ end
80
+
81
+ InstagramCommentList = Struct.new(:comments, :next_before, keyword_init: true) do
82
+ include Enumerable
83
+
84
+ def self.from(hash)
85
+ hash = (hash || {}).transform_keys(&:to_s)
86
+ new(comments: Array(hash["comments"]).map { |item| InstagramComment.from(item) }.freeze,
87
+ next_before: hash["next_before"]).freeze
88
+ end
89
+
90
+ def each(&) = comments.each(&)
91
+ end
92
+
61
93
  # One page of `messages`, newest first. Pass `next_before` back as
62
94
  # `before:` for the next one; it is nil on the last page.
63
95
  MessageList = Struct.new(:messages, :next_before, keyword_init: true) do
@@ -35,6 +35,7 @@ module Vatio
35
35
  # are read back out of the identity token, unverified.
36
36
  Recorded = Struct.new(
37
37
  :id, :to, :brief, :text, :template, :external_ref, :idempotency_key, :identity, :subject, :claims, :mode,
38
+ :channel, :comment_id,
38
39
  keyword_init: true
39
40
  )
40
41
 
@@ -46,16 +47,28 @@ module Vatio
46
47
  "template_params" => 422, "content_required" => 422, "brief_too_long" => 422, "text_too_long" => 422,
47
48
  "invalid_recipient" => 422, "not_a_test_phone" => 422, "identity_not_configured" => 422,
48
49
  "invalid_identity" => 422, "whatsapp_not_connected" => 409, "whatsapp_paused" => 409,
50
+ "whatsapp_number_unhealthy" => 409,
49
51
  "idempotency_conflict" => 409, "identity_conflict" => 409, "invalid_key" => 401,
50
- "workspace_forbidden" => 403, "not_found" => 404, "rate_limited" => 429, "unavailable" => 503
52
+ "workspace_forbidden" => 403, "not_found" => 404, "rate_limited" => 429, "unavailable" => 503,
53
+ "instagram_not_connected" => 409, "unknown_recipient" => 422, "templates_not_supported" => 422,
54
+ "not_a_test_account" => 422, "comment_not_found" => 404, "comment_reply_expired" => 422,
55
+ "comment_already_replied" => 409, "comment_recipient_mismatch" => 422, "comments_not_enabled" => 409,
56
+ "comments_need_live_key" => 409, "job_closed" => 409, "unknown_job" => 404, "result_too_large" => 413
51
57
  }.freeze
52
58
 
59
+ # A job's callback, as the fake received it: `kind` is :progress or
60
+ # :result, and `body` what was posted.
61
+ Callback = Struct.new(:job_id, :kind, :body, keyword_init: true) do
62
+ def result? = kind == :result
63
+ def progress? = kind == :progress
64
+ end
65
+
53
66
  # `fail_next!(:timeout)` and `fail_next!(:network_error)` raise in the
54
67
  # transport, as a socket would, rather than answering at all.
55
68
  NETWORK_FAILURES = %w[timeout network_error].freeze
56
69
 
57
70
  class << self
58
- attr_writer :templates, :window_open
71
+ attr_writer :templates, :window_open, :instagram_comments
59
72
 
60
73
  # Swaps the transport for the in-memory one, and fills a key and a
61
74
  # workspace if the suite has none, so no test needs a real `vsk_`.
@@ -68,6 +81,8 @@ module Vatio
68
81
  Server.config.server_key = "vsk_test_fake" if Server.config.server_key.to_s.strip.empty?
69
82
  Server.config.workspace = "test" if Server.config.workspace.nil?
70
83
  Server.transport = FakeTransport.new
84
+ @previous[:sleeper] = Job.instance_variable_get(:@sleeper)
85
+ Job.sleeper = ->(_seconds) { }
71
86
  end
72
87
  reset!
73
88
  return self unless block_given?
@@ -83,6 +98,7 @@ module Vatio
83
98
  return unless faking?
84
99
 
85
100
  Server.transport = @previous[:transport]
101
+ Job.sleeper = @previous[:sleeper]
86
102
  Server.config.server_key = @previous[:server_key]
87
103
  Server.config.workspace = @previous[:workspace]
88
104
  @previous = nil
@@ -95,6 +111,7 @@ module Vatio
95
111
  def reset!
96
112
  @templates = []
97
113
  @window_open = true
114
+ @instagram_comments = []
98
115
  fake.reset! if faking?
99
116
  end
100
117
 
@@ -105,6 +122,29 @@ module Vatio
105
122
  # Every request, reads included: [method, path, body].
106
123
  def requests = faking? ? fake.requests : []
107
124
 
125
+ # What your code posted to the jobs' callback URLs, oldest first.
126
+ def callbacks = faking? ? fake.callbacks : []
127
+
128
+ # A job as Vatio would hand one to your async tool's endpoint, to
129
+ # pass to the code under test. Its callbacks land in `callbacks`.
130
+ #
131
+ # job = Vatio::Server::Testing.job
132
+ # MatchTherapistsJob.perform_now(job, reason: "anxiety", area: "Ñuñoa")
133
+ # assert_equal "ok", Vatio::Server::Testing.callbacks.last.body["result"]
134
+ def job(id: "job_test#{rand(1_000_000)}", expires_in: 600)
135
+ Job.new(id: id, callback_url: "#{Server.config.base_url!}#{Job::CALLBACK_PATH}test-#{id}",
136
+ expires_at: Time.now + expires_in)
137
+ end
138
+
139
+ # The headers Vatio calls an async tool with, for a request test of
140
+ # its endpoint:
141
+ #
142
+ # post "/vatio/match", params: { reason: "anxiety" }, headers: Vatio::Server::Testing.job_headers(job)
143
+ def job_headers(job = self.job)
144
+ { Job::HEADER_ID => job.id, Job::HEADER_CALLBACK_URL => job.callback_url,
145
+ Job::HEADER_EXPIRES_AT => job.expires_at&.utc&.iso8601 }.compact
146
+ end
147
+
108
148
  # The next call answers with this refusal instead -- any call, so
109
149
  # `fail_next!(:not_found)` before `message_status` works too.
110
150
  def fail_next!(code, status: nil, message: nil, **details)
@@ -119,6 +159,11 @@ module Vatio
119
159
 
120
160
  def window_open? = @window_open != false
121
161
 
162
+ # What `Vatio::Server.instagram_comments` answers, and the comments a
163
+ # `send_message(comment_id:)` can reply to: Hashes as the API sends
164
+ # them (`id`, `text`, `from`, ...).
165
+ def instagram_comments = @instagram_comments || []
166
+
122
167
  # A webhook request as Vatio would deliver it, for testing your
123
168
  # receiver: post `body` with `headers` to its route.
124
169
  #
@@ -153,7 +198,7 @@ module Vatio
153
198
  # answered from memory.
154
199
  class FakeTransport
155
200
  attr_accessor :fail_next
156
- attr_reader :messages, :requests
201
+ attr_reader :messages, :requests, :callbacks
157
202
 
158
203
  def initialize
159
204
  @lock = Mutex.new
@@ -166,6 +211,7 @@ module Vatio
166
211
  @payloads = {}
167
212
  @by_key = {}
168
213
  @requests = []
214
+ @callbacks = []
169
215
  @fail_next = nil
170
216
  @next_id = 0
171
217
  end
@@ -190,10 +236,13 @@ module Vatio
190
236
  private
191
237
 
192
238
  def route(method, path, query, params, headers)
239
+ return job_callback(path, params) if method == "post" && path.start_with?(Job::CALLBACK_PATH)
240
+
193
241
  shown = method == "get" && path[%r{\Amessages/(\d+)\z}, 1]
194
242
  if method == "post" && path == "messages" then create(params, headers["Idempotency-Key"])
195
243
  elsif method == "get" && path == "messages" then list(query)
196
244
  elsif method == "get" && path == "templates" then json(200, { templates: Testing.templates.map(&:to_h) })
245
+ elsif method == "get" && path == "instagram/comments" then comments(query)
197
246
  elsif shown && @payloads[shown.to_i] then json(200, @payloads[shown.to_i])
198
247
  elsif shown then refusal(404, "not_found", "Resource not found")
199
248
  else refusal(404, "not_found", "No such endpoint in Vatio::Server::Testing: #{method.upcase} #{path}")
@@ -210,12 +259,31 @@ module Vatio
210
259
  end
211
260
 
212
261
  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
262
+ comment = nil
263
+ if params["comment_id"]
264
+ comment = Testing.instagram_comments.map { |item| item.transform_keys(&:to_s) }
265
+ .find { |item| item["id"].to_s == params["comment_id"].to_s }
266
+ return refusal(404, "comment_not_found", "No comment #{params["comment_id"]}.") unless comment
267
+ if @messages.any? { |message| message.comment_id == params["comment_id"].to_s }
268
+ return refusal(409, "comment_already_replied", "Comment #{params["comment_id"]} already has its private reply.")
269
+ end
270
+
271
+ to = "@#{comment.dig("from", "username") || comment.dig("from", "id")}"
272
+ end
273
+ instagram = params["channel"].to_s == "instagram" || !comment.nil?
274
+ if comment
275
+ return refusal(422, "templates_not_supported", "Instagram has no message templates.") if params["template"]
276
+ elsif instagram
277
+ return refusal(422, "invalid_recipient", "to must be an Instagram @username or id.") unless to.match?(/\A@?[A-Za-z0-9._]{1,32}\z/)
278
+ return refusal(422, "templates_not_supported", "Instagram has no message templates.") if params["template"]
279
+ elsif to.gsub(/\D/, "").length < 8
280
+ return refusal(422, "invalid_recipient", "to must be a phone number in international format.")
281
+ end
214
282
  if params["brief"].to_s.strip.empty? && params["text"].to_s.strip.empty?
215
283
  return refusal(422, "content_required", "Send `text` or `brief`.")
216
284
  end
217
285
 
218
- mode = if Testing.window_open? then params["text"] ? "text" : "agent"
286
+ mode = if comment || Testing.window_open? then params["text"] ? "text" : "agent"
219
287
  elsif params["template"] then "template"
220
288
  end
221
289
  unless mode
@@ -230,8 +298,8 @@ module Vatio
230
298
 
231
299
  id = (@next_id += 1)
232
300
  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,
301
+ id: id, status: "queued", mode: mode, channel: instagram ? "instagram" : "whatsapp", environment: "live",
302
+ to: instagram ? to : "+#{to.gsub(/\D/, "")}", comment_id: comment && comment["id"].to_s, chat_id: id, external_ref: params["external_ref"], idempotency_key: key,
235
303
  brief: params["brief"], text: text, template: template, content: nil, chat_message_id: nil,
236
304
  delivery: nil, error: nil, sent_at: nil, replied_at: nil, created_at: Time.now.utc.iso8601
237
305
  }
@@ -241,11 +309,44 @@ module Vatio
241
309
  id: id, to: params["to"], brief: params["brief"], text: params["text"], template: params["template"],
242
310
  external_ref: params["external_ref"], idempotency_key: key, identity: params["identity"],
243
311
  subject: claims["sub"], claims: claims.reject { |name, _| Identity::REGISTERED_CLAIMS.include?(name) },
244
- mode: mode
312
+ mode: mode, channel: instagram ? "instagram" : "whatsapp", comment_id: comment && comment["id"].to_s
245
313
  ).freeze
246
314
  json(202, payload)
247
315
  end
248
316
 
317
+ # As the callback endpoint answers: progress 202 while the job runs,
318
+ # the result 200 once, the same result again 200 with `duplicate`,
319
+ # anything after a different end 409.
320
+ def job_callback(path, params)
321
+ job_id = path.delete_prefix(Job::CALLBACK_PATH)[/\Atest-(.+)\z/, 1]
322
+ finished = @callbacks.find { |callback| callback.job_id == job_id && callback.result? }
323
+ progress = params.key?("progress") && !params.key?("result")
324
+
325
+ if finished
326
+ if progress || finished.body != params
327
+ return refusal(409, "job_closed", "Job #{job_id} already ended.", status: finished_status(finished.body))
328
+ end
329
+
330
+ return json(200, { job_id: job_id, status: finished_status(params), duplicate: true })
331
+ end
332
+
333
+ @callbacks << Callback.new(job_id: job_id, kind: progress ? :progress : :result, body: params).freeze
334
+ return json(202, { job_id: job_id, status: "pending" }) if progress
335
+
336
+ json(200, { job_id: job_id, status: finished_status(params) })
337
+ end
338
+
339
+ def finished_status(body) = body["result"] == "error" ? "failed" : "completed"
340
+
341
+ def comments(query)
342
+ items = Testing.instagram_comments.map { |item| JSON.parse(JSON.generate(item)) }
343
+ if query["replyable"] == "true"
344
+ replied = @messages.filter_map(&:comment_id)
345
+ items = items.reject { |item| replied.include?(item["id"].to_s) }
346
+ end
347
+ json(200, { comments: items.first((query["limit"] || 50).to_i.clamp(1, 50)), next_before: nil })
348
+ end
349
+
249
350
  def list(query)
250
351
  items = @payloads.values.reverse
251
352
  items = items.select { |item| item["external_ref"] == query["external_ref"] } if query["external_ref"]
@@ -38,7 +38,8 @@ module Vatio
38
38
  module ClassMethods
39
39
  # One or more event types -- message.sent, message.failed,
40
40
  # message.delivered, message.read, message.delivery_failed,
41
- # message.replied, ping. The block runs on the controller instance.
41
+ # message.replied, feedback.submitted, ping. The block runs on the
42
+ # controller instance.
42
43
  def on_vatio_event(*types, &handler)
43
44
  raise ArgumentError, "vatio server: on_vatio_event needs a block" unless handler
44
45
 
data/lib/vatio/server.rb CHANGED
@@ -12,6 +12,8 @@ require_relative "server/transport"
12
12
  require_relative "server/client"
13
13
  require_relative "server/webhook"
14
14
  require_relative "server/webhook_receiver"
15
+ require_relative "server/job"
16
+ require_relative "server/async_tool"
15
17
 
16
18
  module Vatio
17
19
  # The part of this gem that talks to Vatio, and only if you ask for it:
@@ -20,7 +22,8 @@ module Vatio
20
22
  #
21
23
  # Your backend calls the server API with a server key (`vsk_`) to have the
22
24
  # agent write to someone first -- an order that shipped, an appointment
23
- # tomorrow -- and hears back through a signed webhook.
25
+ # tomorrow -- and hears back through a signed webhook. And it reports on
26
+ # the jobs its async tools started (Job, AsyncTool), which needs no key.
24
27
  #
25
28
  # require "vatio/server"
26
29
  #
@@ -69,6 +72,7 @@ module Vatio
69
72
  def message_status(id) = client.message_status(id)
70
73
  def messages(**filters) = client.messages(**filters)
71
74
  def templates = client.templates
75
+ def instagram_comments(**filters) = client.instagram_comments(**filters)
72
76
  def identity_for(**options) = client.identity_for(**options)
73
77
  end
74
78
  end
@@ -83,3 +87,8 @@ if defined?(ActiveJob)
83
87
  elsif defined?(ActiveSupport) && ActiveSupport.respond_to?(:on_load)
84
88
  ActiveSupport.on_load(:active_job) { require_relative "server/send_message_job" }
85
89
  end
90
+ # So a Job can be an ActiveJob argument. Once ActiveJob::Base loads, which in
91
+ # a Rails app is after this file and runs this at once if it already has.
92
+ if defined?(ActiveSupport) && ActiveSupport.respond_to?(:on_load)
93
+ ActiveSupport.on_load(:active_job) { require_relative "server/job_serializer" }
94
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vatio-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Urcalab
@@ -45,9 +45,12 @@ files:
45
45
  - lib/vatio/identity/version.rb
46
46
  - lib/vatio/sdk.rb
47
47
  - lib/vatio/server.rb
48
+ - lib/vatio/server/async_tool.rb
48
49
  - lib/vatio/server/client.rb
49
50
  - lib/vatio/server/config.rb
50
51
  - lib/vatio/server/errors.rb
52
+ - lib/vatio/server/job.rb
53
+ - lib/vatio/server/job_serializer.rb
51
54
  - lib/vatio/server/message.rb
52
55
  - lib/vatio/server/send_message_job.rb
53
56
  - lib/vatio/server/template.rb