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 +4 -4
- data/README.md +83 -6
- data/lib/vatio/identity/version.rb +1 -1
- data/lib/vatio/server/async_tool.rb +40 -0
- data/lib/vatio/server/client.rb +25 -4
- data/lib/vatio/server/errors.rb +8 -0
- data/lib/vatio/server/job.rb +235 -0
- data/lib/vatio/server/job_serializer.rb +20 -0
- data/lib/vatio/server/message.rb +33 -1
- data/lib/vatio/server/testing.rb +109 -8
- data/lib/vatio/server/webhook_receiver.rb +2 -1
- data/lib/vatio/server.rb +10 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f58bd6f7a713426a74eb89e6393bcd5b678d02abc6e29cc5e9e8286a1bc6366b
|
|
4
|
+
data.tar.gz: 36b34dfc45e5f65e74f24f14d74c54833a55ef11f8339396c3af7e0277db8b7b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
196
|
-
can only write to a test phone verified
|
|
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
|
|
@@ -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
|
data/lib/vatio/server/client.rb
CHANGED
|
@@ -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
|
|
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
|
|
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)
|
data/lib/vatio/server/errors.rb
CHANGED
|
@@ -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)
|
data/lib/vatio/server/message.rb
CHANGED
|
@@ -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
|
data/lib/vatio/server/testing.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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
|