kavchat 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +79 -0
- data/lib/kavchat/client.rb +131 -0
- data/lib/kavchat/errors.rb +31 -0
- data/lib/kavchat/identity.rb +48 -0
- data/lib/kavchat/models.rb +90 -0
- data/lib/kavchat/version.rb +5 -0
- data/lib/kavchat/webhook.rb +68 -0
- data/lib/kavchat.rb +9 -0
- metadata +84 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: aa8a26f7733da2ec296d48c70dacd3fdbb5322bb947392ea2403001b980115c9
|
|
4
|
+
data.tar.gz: d50e780566c163f226fbab6c8bf32741fcaec72e05ca4d88c47b512cfd0b527f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a1112f8f47542bd4d134c1ab614441d71e8482c5c57d5fec83fe55c30d2fb9fed6622a5c30b9ce6990748a29f3357a7873ded02498678ae058f8b0bf40f38803
|
|
7
|
+
data.tar.gz: d8cd1864977262b1dec77ab6276ce565af0bdd09e390873aa7aff2afbc0b539a21483ec408ad3c33f876a3390bf7e281ada63371c99566e222516c28e16c2f07
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 maxsave
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# kavchat (Ruby)
|
|
2
|
+
|
|
3
|
+
Kavchat server SDK for Ruby 2.6+. Standard library only (`net/http`, `openssl`, `json`).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "kavchat"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
require "kavchat"
|
|
15
|
+
|
|
16
|
+
kavchat = Kavchat::Client.new(
|
|
17
|
+
ENV.fetch("KAVCHAT_SECRET_KEY"),
|
|
18
|
+
# base_url: "https://api.kavchat.com",
|
|
19
|
+
# timeout: 30,
|
|
20
|
+
# locale: "en"
|
|
21
|
+
)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Identify your users
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
token = kavchat.create_user_token(
|
|
28
|
+
user_id: "user_123", # required, 1 to 128 characters
|
|
29
|
+
phone: "+972501234567",
|
|
30
|
+
name: "Dana",
|
|
31
|
+
email: "dana@example.com",
|
|
32
|
+
expires_in: 7 * 24 * 3600 # seconds, default 7 days, at most 30 days
|
|
33
|
+
)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`Kavchat.create_user_token(secret_key, user_id: ...)` is the module level version.
|
|
37
|
+
|
|
38
|
+
## Verify webhooks (Rails)
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
class KavchatWebhooksController < ActionController::API
|
|
42
|
+
def create
|
|
43
|
+
event = KAVCHAT.verify_webhook(request.raw_post, request.headers["Kavchat-Signature"])
|
|
44
|
+
Rails.logger.info("New ticket #{event.data.number}") if event.type == "ticket.created"
|
|
45
|
+
head :ok
|
|
46
|
+
rescue Kavchat::WebhookVerificationError => e
|
|
47
|
+
render plain: e.code, status: :bad_request
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`Client#verify_webhook` returns a `Kavchat::WebhookEvent`. `Kavchat.verify_webhook` returns the plain Hash.
|
|
53
|
+
|
|
54
|
+
## Server API
|
|
55
|
+
|
|
56
|
+
Responses are read-only models with snake_case readers (`ticket.topic_name`). `#[]` and `#to_h` give the raw hash.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
kavchat.get_workspace
|
|
60
|
+
kavchat.list_tickets(status: "open", limit: 50, before: 120)
|
|
61
|
+
kavchat.get_ticket(id)
|
|
62
|
+
kavchat.set_ticket_status(id, "closed")
|
|
63
|
+
kavchat.get_reports(from: Time.now - 7 * 86_400, to: Time.now)
|
|
64
|
+
kavchat.get_settings
|
|
65
|
+
kavchat.update_settings(audience: "testers", tester_phones: ["+972501234567"])
|
|
66
|
+
kavchat.list_releases
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Errors raise `Kavchat::ApiError` with `status`, `code`, `message` and `details`. `status` is 0 for `timeout` and `network_error`.
|
|
70
|
+
|
|
71
|
+
## Tests
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
rake test
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## License and support
|
|
78
|
+
|
|
79
|
+
MIT. Documentation: https://kavchat.com/docs/. Questions: support@kavchat.com.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "time"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Kavchat
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.kavchat.com"
|
|
10
|
+
|
|
11
|
+
# Kavchat Server API client. Keep the secret key on your backend only.
|
|
12
|
+
#
|
|
13
|
+
# kavchat = Kavchat::Client.new(ENV.fetch("KAVCHAT_SECRET_KEY"))
|
|
14
|
+
class Client
|
|
15
|
+
attr_reader :base_url, :timeout, :locale
|
|
16
|
+
|
|
17
|
+
def initialize(secret_key, base_url: DEFAULT_BASE_URL, timeout: 30, locale: nil)
|
|
18
|
+
raise ArgumentError, "secret_key is required" if !secret_key.is_a?(String) || secret_key.empty?
|
|
19
|
+
|
|
20
|
+
@secret_key = secret_key
|
|
21
|
+
@base_url = base_url.sub(%r{/+\z}, "")
|
|
22
|
+
@timeout = timeout
|
|
23
|
+
@locale = locale
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def inspect
|
|
27
|
+
"#<Kavchat::Client base_url=#{@base_url.inspect}>"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Signs an identity token for one of your users. See Kavchat.create_user_token.
|
|
31
|
+
def create_user_token(**options)
|
|
32
|
+
Kavchat.create_user_token(@secret_key, **options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Verifies a webhook request and returns a Kavchat::WebhookEvent. See Kavchat.verify_webhook.
|
|
36
|
+
def verify_webhook(raw_body, signature_header, tolerance_seconds = DEFAULT_TOLERANCE_SECONDS, now: nil)
|
|
37
|
+
WebhookEvent.new(Kavchat.verify_webhook(@secret_key, raw_body, signature_header, tolerance_seconds, now: now))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def get_workspace
|
|
41
|
+
Workspace.new(request(:get, "/workspace"))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Newest first. Page with before: the number of the last ticket you have.
|
|
45
|
+
def list_tickets(status: nil, limit: nil, before: nil)
|
|
46
|
+
request(:get, "/tickets", query: { status: status, limit: limit, before: before }).map { |t| Ticket.new(t) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def get_ticket(id)
|
|
50
|
+
Ticket.new(request(:get, "/tickets/#{escape(id)}"))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# status: "open" or "closed"
|
|
54
|
+
def set_ticket_status(id, status)
|
|
55
|
+
Ticket.new(request(:put, "/tickets/#{escape(id)}/status", body: { status: status.to_s }))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Defaults to the last 30 days. from/to accept Time or an ISO 8601 string.
|
|
59
|
+
def get_reports(from: nil, to: nil)
|
|
60
|
+
Report.new(request(:get, "/reports", query: { from: iso(from), to: iso(to) }))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def get_settings
|
|
64
|
+
Settings.new(request(:get, "/settings"))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Only the audience and the tester phones can be changed from the server.
|
|
68
|
+
def update_settings(audience: nil, tester_phones: nil)
|
|
69
|
+
body = {}
|
|
70
|
+
body[:audience] = audience.to_s unless audience.nil?
|
|
71
|
+
body[:testerPhones] = Array(tester_phones) unless tester_phones.nil?
|
|
72
|
+
Settings.new(request(:put, "/settings", body: body))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def list_releases
|
|
76
|
+
request(:get, "/releases").map { |r| ReleaseSummary.new(r) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def escape(id)
|
|
82
|
+
URI.encode_www_form_component(id.to_s).gsub("+", "%20")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def iso(value)
|
|
86
|
+
value.respond_to?(:utc) ? value.utc.iso8601(3) : value
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def request(method, path, query: nil, body: nil)
|
|
90
|
+
uri = URI("#{@base_url}/v1/server#{path}")
|
|
91
|
+
params = (query || {}).reject { |_, v| v.nil? || v == "" }
|
|
92
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
93
|
+
|
|
94
|
+
req = (method == :put ? Net::HTTP::Put : Net::HTTP::Get).new(uri)
|
|
95
|
+
req["Authorization"] = "Bearer #{@secret_key}"
|
|
96
|
+
req["Accept"] = "application/json"
|
|
97
|
+
req["User-Agent"] = "kavchat-ruby/#{VERSION}"
|
|
98
|
+
req["Accept-Language"] = @locale if @locale
|
|
99
|
+
unless body.nil?
|
|
100
|
+
req["Content-Type"] = "application/json"
|
|
101
|
+
req.body = JSON.generate(body)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
res = begin
|
|
105
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
106
|
+
open_timeout: @timeout, read_timeout: @timeout, write_timeout: @timeout) do |http|
|
|
107
|
+
http.request(req)
|
|
108
|
+
end
|
|
109
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
|
|
110
|
+
raise ApiError.new(0, "timeout", "Request timed out after #{@timeout} s")
|
|
111
|
+
rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
|
|
112
|
+
raise ApiError.new(0, "network_error", "Could not reach Kavchat: #{e.message}")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
status = res.code.to_i
|
|
116
|
+
payload = begin
|
|
117
|
+
res.body.nil? || res.body.empty? ? nil : JSON.parse(res.body.dup.force_encoding(Encoding::UTF_8))
|
|
118
|
+
rescue JSON::ParserError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
if !payload.is_a?(Hash) || !(200..299).cover?(status) || payload["success"] != true
|
|
122
|
+
if payload.is_a?(Hash) && payload["code"].is_a?(String)
|
|
123
|
+
raise ApiError.new(status, payload["code"], (payload["message"] || payload["code"]).to_s, payload["details"])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
raise ApiError.new(status, "invalid_response", "Unexpected response from Kavchat (HTTP #{status})")
|
|
127
|
+
end
|
|
128
|
+
payload["data"]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kavchat
|
|
4
|
+
# Base class of every error raised by this SDK.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# The Server API answered with an error, or could not be reached.
|
|
8
|
+
# +status+ is the HTTP status, or 0 when no response arrived
|
|
9
|
+
# (+code+ is then "timeout" or "network_error").
|
|
10
|
+
class ApiError < Error
|
|
11
|
+
attr_reader :status, :code, :details
|
|
12
|
+
|
|
13
|
+
def initialize(status, code, message, details = nil)
|
|
14
|
+
super(message)
|
|
15
|
+
@status = status
|
|
16
|
+
@code = code
|
|
17
|
+
@details = details
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A webhook request failed verification. Answer it with HTTP 400.
|
|
22
|
+
# +code+ is malformed_header, timestamp_out_of_range, invalid_signature or invalid_payload.
|
|
23
|
+
class WebhookVerificationError < Error
|
|
24
|
+
attr_reader :code
|
|
25
|
+
|
|
26
|
+
def initialize(code, message)
|
|
27
|
+
super(message)
|
|
28
|
+
@code = code
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
|
|
7
|
+
module Kavchat
|
|
8
|
+
DEFAULT_TOKEN_TTL_SECONDS = 7 * 24 * 60 * 60
|
|
9
|
+
MAX_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60
|
|
10
|
+
|
|
11
|
+
# base64url of {"alg":"HS256","typ":"JWT"}
|
|
12
|
+
JWT_HEADER = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
13
|
+
private_constant :JWT_HEADER
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Signs an identity token (HS256 JWT) for one of your users.
|
|
18
|
+
# +expires_in+ is in seconds (default 7 days, at most 30 days).
|
|
19
|
+
# +issued_at+ is unix seconds and defaults to now.
|
|
20
|
+
def create_user_token(secret_key, user_id:, phone: nil, name: nil, email: nil,
|
|
21
|
+
expires_in: DEFAULT_TOKEN_TTL_SECONDS, issued_at: nil)
|
|
22
|
+
raise ArgumentError, "secret_key is required" if !secret_key.is_a?(String) || secret_key.empty?
|
|
23
|
+
unless user_id.is_a?(String) && (1..128).cover?(user_id.encode("UTF-16LE").bytesize / 2)
|
|
24
|
+
raise ArgumentError, "user_id must be a string of 1 to 128 characters"
|
|
25
|
+
end
|
|
26
|
+
unless expires_in.is_a?(Integer) && expires_in.positive? && expires_in <= MAX_TOKEN_TTL_SECONDS
|
|
27
|
+
raise ArgumentError, "expires_in must be a whole number of seconds between 1 and #{MAX_TOKEN_TTL_SECONDS}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
iat = (issued_at || Time.now).to_i
|
|
31
|
+
# Claim order is fixed (sub, phone, name, email, iat, exp) so every SDK signs the same bytes.
|
|
32
|
+
claims = { "sub" => user_id }
|
|
33
|
+
claims["phone"] = phone if phone && !phone.empty?
|
|
34
|
+
claims["name"] = name if name && !name.empty?
|
|
35
|
+
claims["email"] = email if email && !email.empty?
|
|
36
|
+
claims["iat"] = iat
|
|
37
|
+
claims["exp"] = iat + expires_in
|
|
38
|
+
|
|
39
|
+
payload = b64url(JSON.generate(claims))
|
|
40
|
+
signature = b64url(OpenSSL::HMAC.digest("SHA256", secret_key, "#{JWT_HEADER}.#{payload}"))
|
|
41
|
+
"#{JWT_HEADER}.#{payload}.#{signature}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def b64url(data)
|
|
45
|
+
Base64.urlsafe_encode64(data, padding: false)
|
|
46
|
+
end
|
|
47
|
+
private_class_method :b64url
|
|
48
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kavchat
|
|
4
|
+
# Read-only view over a JSON object from the API. Fields are exposed as snake_case
|
|
5
|
+
# readers (ticket.topic_name); the raw hash stays available through #[] and #to_h.
|
|
6
|
+
class Model
|
|
7
|
+
def self.fields(mapping)
|
|
8
|
+
mapping.each do |name, (key, type)|
|
|
9
|
+
define_method(name) { convert(@raw[key], type) }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(raw)
|
|
14
|
+
@raw = raw || {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def [](key)
|
|
18
|
+
@raw[key.to_s]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
@raw
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ==(other)
|
|
26
|
+
other.is_a?(self.class) && other.to_h == to_h
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def convert(value, type)
|
|
32
|
+
return value if type.nil? || value.nil?
|
|
33
|
+
return value.map { |v| convert(v, type.first) } if type.is_a?(Array)
|
|
34
|
+
|
|
35
|
+
type.new(value)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class TranscriptEntry < Model
|
|
40
|
+
fields from: ["from"], text: ["text"], step_id: ["stepId"], at: ["at"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Ticket < Model
|
|
44
|
+
fields id: ["id"], number: ["number"], status: ["status"], name: ["name"], email: ["email"],
|
|
45
|
+
phone: ["phone"], user_id: ["userId"], topic_id: ["topicId"], topic_name: ["topicName"],
|
|
46
|
+
script_id: ["scriptId"], session_id: ["sessionId"], message: ["message"],
|
|
47
|
+
transcript: ["transcript", [TranscriptEntry]], created_at: ["createdAt"], updated_at: ["updatedAt"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class ContactTopic < Model
|
|
51
|
+
fields id: ["id"], name: ["name"]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class Settings < Model
|
|
55
|
+
fields enabled: ["enabled"], audience: ["audience"], tester_phones: ["testerPhones"], bot_name: ["botName"],
|
|
56
|
+
avatar_text: ["avatarText"], avatar_image_url: ["avatarImageUrl"], subtitle: ["subtitle"],
|
|
57
|
+
primary_color: ["primaryColor"], position: ["position"], contact_topics: ["contactTopics", [ContactTopic]]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class Workspace < Model
|
|
61
|
+
fields id: ["id"], name: ["name"], locale: ["locale"], default_country: ["defaultCountry"], plan: ["plan"],
|
|
62
|
+
public_key: ["publicKey"], secret_key_hint: ["secretKeyHint"], webhook_url: ["webhookUrl"]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class ReleaseScript < Model
|
|
66
|
+
fields id: ["id"], kind: ["kind"], name: ["name"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class ReleaseSummary < Model
|
|
70
|
+
fields version: ["version"], note: ["note"], restored_from: ["restoredFrom"], published_at: ["publishedAt"],
|
|
71
|
+
scripts: ["scripts", [ReleaseScript]]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class ReportTotals < Model
|
|
75
|
+
fields sessions: ["sessions"], open: ["open"], answered: ["answered"], contact: ["contact"], abandoned: ["abandoned"]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class UnmatchedQuestion < Model
|
|
79
|
+
fields group_key: ["groupKey"], sample: ["sample"], count: ["count"], last_at: ["lastAt"]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class Report < Model
|
|
83
|
+
fields from: ["from"], to: ["to"], totals: ["totals", ReportTotals], by_script: ["byScript"],
|
|
84
|
+
drop_off: ["dropOff"], unmatched: ["unmatched", [UnmatchedQuestion]]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class WebhookEvent < Model
|
|
88
|
+
fields type: ["type"], data: ["data", Ticket]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Kavchat
|
|
7
|
+
SIGNATURE_HEADER = "Kavchat-Signature"
|
|
8
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Checks the Kavchat-Signature header and returns the parsed event (a Hash).
|
|
13
|
+
# +raw_body+ must be the exact bytes received, before any JSON parsing.
|
|
14
|
+
# Raises Kavchat::WebhookVerificationError when the request is not authentic or too old.
|
|
15
|
+
def verify_webhook(secret_key, raw_body, signature_header, tolerance_seconds = DEFAULT_TOLERANCE_SECONDS, now: nil)
|
|
16
|
+
timestamp = nil
|
|
17
|
+
bad_timestamp = false
|
|
18
|
+
signatures = []
|
|
19
|
+
signature_header.to_s.split(",").each do |part|
|
|
20
|
+
key, sep, value = part.partition("=")
|
|
21
|
+
next if sep.empty?
|
|
22
|
+
|
|
23
|
+
key = key.strip
|
|
24
|
+
value = value.strip
|
|
25
|
+
if key == "t"
|
|
26
|
+
if value.match?(/\A\d+\z/)
|
|
27
|
+
timestamp = value.to_i
|
|
28
|
+
else
|
|
29
|
+
bad_timestamp = true
|
|
30
|
+
end
|
|
31
|
+
elsif key == "v1" && !value.empty?
|
|
32
|
+
signatures << value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
if timestamp.nil? || bad_timestamp || signatures.empty?
|
|
36
|
+
raise WebhookVerificationError.new("malformed_header", "Kavchat-Signature header is missing or malformed")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
current = (now || Time.now).to_i
|
|
40
|
+
if (current - timestamp).abs > tolerance_seconds
|
|
41
|
+
raise WebhookVerificationError.new("timestamp_out_of_range", "Webhook timestamp is outside the tolerance")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
body = raw_body.to_s.dup.force_encoding(Encoding::BINARY)
|
|
45
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret_key, "#{timestamp}.".b + body)
|
|
46
|
+
unless signatures.any? { |sig| secure_compare(expected, sig) }
|
|
47
|
+
raise WebhookVerificationError.new("invalid_signature", "Webhook signature does not match")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
begin
|
|
51
|
+
JSON.parse(body.force_encoding(Encoding::UTF_8))
|
|
52
|
+
rescue JSON::ParserError, EncodingError
|
|
53
|
+
raise WebhookVerificationError.new("invalid_payload", "Webhook body is not valid JSON")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Constant time comparison (OpenSSL.secure_compare needs Ruby 2.7+).
|
|
58
|
+
def secure_compare(a, b)
|
|
59
|
+
a = a.b
|
|
60
|
+
b = b.b
|
|
61
|
+
return false unless a.bytesize == b.bytesize
|
|
62
|
+
|
|
63
|
+
diff = 0
|
|
64
|
+
a.bytes.zip(b.bytes) { |x, y| diff |= x ^ y }
|
|
65
|
+
diff.zero?
|
|
66
|
+
end
|
|
67
|
+
private_class_method :secure_compare
|
|
68
|
+
end
|
data/lib/kavchat.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Kavchat server SDK: identity tokens, webhook verification and the Server API.
|
|
4
|
+
require_relative "kavchat/version"
|
|
5
|
+
require_relative "kavchat/errors"
|
|
6
|
+
require_relative "kavchat/identity"
|
|
7
|
+
require_relative "kavchat/webhook"
|
|
8
|
+
require_relative "kavchat/models"
|
|
9
|
+
require_relative "kavchat/client"
|
metadata
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: kavchat
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.4
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- maxsave
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-25 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.11'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.11'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '12'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '12'
|
|
41
|
+
description: Sign identity tokens for the Kavchat widget and apps, verify ticket webhooks,
|
|
42
|
+
and call the Kavchat Server API. No runtime dependencies.
|
|
43
|
+
email:
|
|
44
|
+
- support@kavchat.com
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- LICENSE
|
|
50
|
+
- README.md
|
|
51
|
+
- lib/kavchat.rb
|
|
52
|
+
- lib/kavchat/client.rb
|
|
53
|
+
- lib/kavchat/errors.rb
|
|
54
|
+
- lib/kavchat/identity.rb
|
|
55
|
+
- lib/kavchat/models.rb
|
|
56
|
+
- lib/kavchat/version.rb
|
|
57
|
+
- lib/kavchat/webhook.rb
|
|
58
|
+
homepage: https://kavchat.com
|
|
59
|
+
licenses:
|
|
60
|
+
- MIT
|
|
61
|
+
metadata:
|
|
62
|
+
homepage_uri: https://kavchat.com
|
|
63
|
+
documentation_uri: https://kavchat.com/docs/
|
|
64
|
+
post_install_message:
|
|
65
|
+
rdoc_options: []
|
|
66
|
+
require_paths:
|
|
67
|
+
- lib
|
|
68
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '2.6'
|
|
73
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: '0'
|
|
78
|
+
requirements: []
|
|
79
|
+
rubygems_version: 3.0.3.1
|
|
80
|
+
signing_key:
|
|
81
|
+
specification_version: 4
|
|
82
|
+
summary: 'Kavchat server SDK: identity tokens, webhook verification and the Server
|
|
83
|
+
API'
|
|
84
|
+
test_files: []
|