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 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kavchat
4
+ VERSION = "0.0.4"
5
+ 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: []