bfocus 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +405 -0
- data/lib/bfocus/client.rb +82 -0
- data/lib/bfocus/codec.rb +113 -0
- data/lib/bfocus/errors.rb +104 -0
- data/lib/bfocus/page.rb +94 -0
- data/lib/bfocus/resources/ai_agents.rb +37 -0
- data/lib/bfocus/resources/base.rb +66 -0
- data/lib/bfocus/resources/customers.rb +174 -0
- data/lib/bfocus/resources/knowledge_base.rb +176 -0
- data/lib/bfocus/resources/products.rb +43 -0
- data/lib/bfocus/resources/release_notes.rb +70 -0
- data/lib/bfocus/transport.rb +261 -0
- data/lib/bfocus/unset.rb +27 -0
- data/lib/bfocus/version.rb +7 -0
- data/lib/bfocus/widget.rb +30 -0
- data/lib/bfocus.rb +34 -0
- metadata +64 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bfocus
|
|
4
|
+
module Resources
|
|
5
|
+
# Catálogo de produtos — `client.products`.
|
|
6
|
+
#
|
|
7
|
+
# Produto: `"id"`, `"slug"`, `"name"`, `"description"`, `"color"`, `"icon"`, `"is_active"`,
|
|
8
|
+
# `"sort_order"`, `"current_version"`, `"ai_level"`, `"created_at"`, `"updated_at"`.
|
|
9
|
+
class Products < Base
|
|
10
|
+
# Produtos do catálogo. `GET /products`
|
|
11
|
+
# @param include_inactive [Boolean, nil] `true` inclui os arquivados.
|
|
12
|
+
# @return [Array<Hash>]
|
|
13
|
+
def list(include_inactive: nil, timeout: nil)
|
|
14
|
+
call("GET", "/products", query: { "include_inactive" => include_inactive }, timeout: timeout)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Um produto. `GET /products/{slug}`
|
|
18
|
+
# @return [Hash]
|
|
19
|
+
def get(slug, timeout: nil)
|
|
20
|
+
call("GET", "/products/#{segment(slug, 'slug')}", timeout: timeout)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Cria ou atualiza um produto pelo `slug`. Só o que vier muda. `PUT /products/{slug}`
|
|
24
|
+
# @return [Hash] o produto.
|
|
25
|
+
def upsert(slug, name: UNSET, description: UNSET, color: UNSET, icon: UNSET,
|
|
26
|
+
is_active: UNSET, sort_order: UNSET, idempotency_key: nil, timeout: nil)
|
|
27
|
+
body = compact(
|
|
28
|
+
"name" => name, "description" => description, "color" => color, "icon" => icon,
|
|
29
|
+
"is_active" => is_active, "sort_order" => sort_order
|
|
30
|
+
)
|
|
31
|
+
call("PUT", "/products/#{segment(slug, 'slug')}",
|
|
32
|
+
body: body, idempotency_key: idempotency_key, timeout: timeout)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Arquiva um produto (não apaga). `DELETE /products/{slug}`
|
|
36
|
+
# @return [Hash] o produto, com `"is_active" => false`.
|
|
37
|
+
def archive(slug, idempotency_key: nil, timeout: nil)
|
|
38
|
+
call("DELETE", "/products/#{segment(slug, 'slug')}",
|
|
39
|
+
idempotency_key: idempotency_key, timeout: timeout)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bfocus
|
|
4
|
+
module Resources
|
|
5
|
+
# Release notes por produto — `client.release_notes`.
|
|
6
|
+
#
|
|
7
|
+
# Release note: `"id"`, `"product"`, `"version"`, `"title"`, `"description_html"`,
|
|
8
|
+
# `"audience"`, `"is_published"`, `"require_ack_internal"`, `"require_ack_external"`,
|
|
9
|
+
# `"published_at"`, `"created_at"`, `"updated_at"`.
|
|
10
|
+
class ReleaseNotes < Base
|
|
11
|
+
# Uma página de release notes. `GET /products/{slug}/release-notes`
|
|
12
|
+
# @param published [Boolean, nil] `true` só publicadas, `false` só rascunhos, `nil` todas.
|
|
13
|
+
# @return [Bfocus::Page]
|
|
14
|
+
def list(product_slug, published: nil, page: nil, page_size: nil, timeout: nil)
|
|
15
|
+
paged(base(product_slug), { "published" => published, "page" => page, "page_size" => page_size }, timeout)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Todas as release notes do produto, página a página (`page_size` padrão 100).
|
|
19
|
+
# @return [Enumerator::Lazy<Hash>]
|
|
20
|
+
def list_all(product_slug, published: nil, page_size: 100, timeout: nil)
|
|
21
|
+
base(product_slug) # valida já, não na 1ª iteração
|
|
22
|
+
iterate do |number|
|
|
23
|
+
list(product_slug, published: published, page: number, page_size: page_size, timeout: timeout)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Uma release note. `GET /products/{slug}/release-notes/{version}`
|
|
28
|
+
# @return [Hash]
|
|
29
|
+
def get(product_slug, version, timeout: nil)
|
|
30
|
+
call("GET", "#{base(product_slug)}/#{segment(version, 'version')}", timeout: timeout)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Cria ou atualiza a release note de uma versão (SemVer; aceita `v` na frente).
|
|
34
|
+
# `PUT /products/{slug}/release-notes/{version}`. Com `publish: true` já publica — é o
|
|
35
|
+
# caminho para publicar direto do CI.
|
|
36
|
+
#
|
|
37
|
+
# @param audience [String] `"internal"`, `"external"` ou `"both"`.
|
|
38
|
+
# @param description_markdown [String] alternativa a `description_html` (a API converte).
|
|
39
|
+
# @return [Hash] a release note.
|
|
40
|
+
def upsert(product_slug, version, title: UNSET, description_html: UNSET,
|
|
41
|
+
description_markdown: UNSET, audience: UNSET, require_ack_internal: UNSET,
|
|
42
|
+
require_ack_external: UNSET, publish: UNSET, idempotency_key: nil, timeout: nil)
|
|
43
|
+
body = compact(
|
|
44
|
+
"title" => title,
|
|
45
|
+
"description_html" => description_html,
|
|
46
|
+
"description_markdown" => description_markdown,
|
|
47
|
+
"audience" => audience,
|
|
48
|
+
"require_ack_internal" => require_ack_internal,
|
|
49
|
+
"require_ack_external" => require_ack_external,
|
|
50
|
+
"publish" => publish
|
|
51
|
+
)
|
|
52
|
+
call("PUT", "#{base(product_slug)}/#{segment(version, 'version')}",
|
|
53
|
+
body: body, idempotency_key: idempotency_key, timeout: timeout)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Publica uma release note. `POST /products/{slug}/release-notes/{version}/publish`
|
|
57
|
+
# @return [Hash] a release note.
|
|
58
|
+
def publish(product_slug, version, idempotency_key: nil, timeout: nil)
|
|
59
|
+
call("POST", "#{base(product_slug)}/#{segment(version, 'version')}/publish",
|
|
60
|
+
idempotency_key: idempotency_key, timeout: timeout)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def base(product_slug)
|
|
66
|
+
"/products/#{segment(product_slug, 'product_slug')}/release-notes"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bfocus
|
|
4
|
+
# URL de produção da API (padrão do {Client}). Em dev: `http://localhost:8000`.
|
|
5
|
+
DEFAULT_BASE_URL = "https://api.bfocus.com.br"
|
|
6
|
+
# Prefixo das rotas da API pública.
|
|
7
|
+
API_PREFIX = "/api/v1/integration"
|
|
8
|
+
# Vai em `X-Bfocus-Client` e `User-Agent`. É por ele que a API sabe qual versão da SDK a
|
|
9
|
+
# conta roda — e avisa quando uma correção exigir atualizar.
|
|
10
|
+
CLIENT_ID = "bfocus-ruby/#{VERSION}".freeze
|
|
11
|
+
# Segundos por tentativa.
|
|
12
|
+
DEFAULT_TIMEOUT = 30
|
|
13
|
+
# Novas tentativas além da primeira.
|
|
14
|
+
DEFAULT_MAX_RETRIES = 2
|
|
15
|
+
|
|
16
|
+
# Faz UMA chamada lógica: 1 tentativa + até `max_retries` novas tentativas, com os mesmos
|
|
17
|
+
# `X-Request-Id` e `Idempotency-Key`. Sem estado mutável entre chamadas (uma conexão por
|
|
18
|
+
# tentativa), então um {Client} pode ser compartilhado entre threads.
|
|
19
|
+
# @api private
|
|
20
|
+
class Transport
|
|
21
|
+
RETRY_STATUSES = [429, 502, 503, 504].freeze
|
|
22
|
+
WRITE_METHODS = %w[POST PUT PATCH DELETE].freeze
|
|
23
|
+
MAX_RETRY_AFTER = 60.0
|
|
24
|
+
MAX_BACKOFF = 8.0
|
|
25
|
+
|
|
26
|
+
# Métodos que "esperam" corpo: sem corpo, vão com `Content-Length: 0` (e sem Content-Type).
|
|
27
|
+
BODY_METHODS = %w[POST PUT PATCH].freeze
|
|
28
|
+
|
|
29
|
+
# Falhas de transporte que viram {NetworkError} (e são repetidas). `Net::OpenTimeout` e
|
|
30
|
+
# `Net::ReadTimeout` são `Timeout::Error`; `ECONNREFUSED`/`ECONNRESET` são `SystemCallError`.
|
|
31
|
+
NETWORK_ERRORS = [
|
|
32
|
+
SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError,
|
|
33
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError
|
|
34
|
+
].tap { |list| list << Zlib::Error if defined?(Zlib::Error) }.freeze
|
|
35
|
+
|
|
36
|
+
attr_reader :base_url, :timeout, :max_retries
|
|
37
|
+
# Espera entre tentativas (`#call(segundos)`). Substituível: os testes não dormem.
|
|
38
|
+
attr_accessor :sleeper
|
|
39
|
+
|
|
40
|
+
def initialize(api_key, base_url:, timeout:, max_retries:, sleeper: nil)
|
|
41
|
+
@api_key = api_key
|
|
42
|
+
@base_url = base_url.sub(%r{/+\z}, "")
|
|
43
|
+
begin
|
|
44
|
+
@uri = URI.parse(@base_url)
|
|
45
|
+
rescue URI::InvalidURIError
|
|
46
|
+
@uri = nil
|
|
47
|
+
end
|
|
48
|
+
unless @uri.is_a?(URI::HTTP) && @uri.hostname && !@uri.hostname.empty?
|
|
49
|
+
raise ArgumentError, "Bfocus::Client: base_url inválida #{base_url.inspect} (use http:// ou https://)."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@base_path = @uri.path.to_s
|
|
53
|
+
@timeout = timeout
|
|
54
|
+
@max_retries = max_retries
|
|
55
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# `Retry-After` em segundos (número ou data HTTP); `nil` se ausente/inválido.
|
|
59
|
+
# @return [Integer, Float, nil]
|
|
60
|
+
def self.parse_retry_after(raw)
|
|
61
|
+
return nil if raw.nil?
|
|
62
|
+
|
|
63
|
+
text = raw.to_s.strip
|
|
64
|
+
return nil if text.empty?
|
|
65
|
+
|
|
66
|
+
seconds =
|
|
67
|
+
if text.match?(/\A\d+(?:\.\d+)?\z/)
|
|
68
|
+
text.to_f
|
|
69
|
+
else
|
|
70
|
+
begin
|
|
71
|
+
Time.httpdate(text) - Time.now
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
return nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
return nil if seconds.nan? || seconds.infinite?
|
|
77
|
+
|
|
78
|
+
seconds = 0.0 if seconds.negative?
|
|
79
|
+
seconds == seconds.floor ? seconds.to_i : seconds
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `min(8, 0.5 × 2^tentativa)` s + jitter de até 25%.
|
|
83
|
+
def self.backoff(attempt)
|
|
84
|
+
base = [MAX_BACKOFF, 0.5 * (2**attempt)].min
|
|
85
|
+
base + (Random.rand * base * 0.25)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Espera antes da próxima tentativa: `Retry-After` (teto 60 s) ou backoff exponencial.
|
|
89
|
+
def retry_delay(attempt, retry_after)
|
|
90
|
+
seconds = self.class.parse_retry_after(retry_after)
|
|
91
|
+
return [seconds.to_f, MAX_RETRY_AFTER].min unless seconds.nil?
|
|
92
|
+
|
|
93
|
+
self.class.backoff(attempt)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Executa a chamada e devolve `[data, pagination]` do envelope.
|
|
97
|
+
#
|
|
98
|
+
# `body: nil` significa SEM corpo (o JSON `null` nunca é um corpo válido aqui).
|
|
99
|
+
# @raise [Bfocus::Error]
|
|
100
|
+
def request(method, path, query: nil, body: nil, idempotency_key: nil, timeout: nil)
|
|
101
|
+
method = method.to_s.upcase
|
|
102
|
+
per_try = timeout.nil? ? @timeout : timeout
|
|
103
|
+
unless per_try.is_a?(Numeric) && per_try.positive?
|
|
104
|
+
raise ArgumentError, "timeout precisa ser > 0 (segundos)."
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
target = @base_path + API_PREFIX + path + Codec.query_string(query)
|
|
108
|
+
request_id = SecureRandom.uuid.delete("-")
|
|
109
|
+
headers = {
|
|
110
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
111
|
+
"Accept" => "application/json",
|
|
112
|
+
"X-Bfocus-Client" => CLIENT_ID,
|
|
113
|
+
"User-Agent" => CLIENT_ID,
|
|
114
|
+
# Mesmo id em todas as tentativas desta chamada: é como o suporte correlaciona.
|
|
115
|
+
"X-Request-Id" => request_id
|
|
116
|
+
}
|
|
117
|
+
if WRITE_METHODS.include?(method)
|
|
118
|
+
# Mesma chave em todas as tentativas: a API devolve a resposta original
|
|
119
|
+
# (Idempotent-Replayed: true) em vez de executar de novo.
|
|
120
|
+
key = idempotency_key.to_s
|
|
121
|
+
headers["Idempotency-Key"] = key.empty? ? SecureRandom.uuid : key
|
|
122
|
+
end
|
|
123
|
+
payload = nil
|
|
124
|
+
unless body.nil?
|
|
125
|
+
payload = JSON.generate(Codec.jsonable(body))
|
|
126
|
+
headers["Content-Type"] = "application/json"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
attempt = 0
|
|
130
|
+
while true # rubocop:disable Style/InfiniteLoop -- `loop` engoliria um StopIteration do sleeper
|
|
131
|
+
begin
|
|
132
|
+
status, resp_headers, raw = perform(method, target, headers, payload, per_try)
|
|
133
|
+
rescue *NETWORK_ERRORS => e
|
|
134
|
+
if attempt < @max_retries
|
|
135
|
+
@sleeper.call(self.class.backoff(attempt))
|
|
136
|
+
attempt += 1
|
|
137
|
+
next
|
|
138
|
+
end
|
|
139
|
+
raise NetworkError.new(
|
|
140
|
+
"NETWORK_ERROR: falha ao falar com #{@base_url} (#{e.class}: #{e.message})",
|
|
141
|
+
request_id: request_id
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
return unwrap(status, resp_headers, raw, request_id) if status.between?(200, 299)
|
|
146
|
+
|
|
147
|
+
if RETRY_STATUSES.include?(status) && attempt < @max_retries
|
|
148
|
+
@sleeper.call(retry_delay(attempt, resp_headers["retry-after"]))
|
|
149
|
+
attempt += 1
|
|
150
|
+
next
|
|
151
|
+
end
|
|
152
|
+
raise build_error(status, resp_headers, raw, request_id)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def inspect
|
|
157
|
+
"#<Bfocus::Transport base_url=#{@base_url.inspect}>"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def perform(method, target, headers, payload, timeout)
|
|
163
|
+
http = Net::HTTP.new(@uri.hostname, @uri.port)
|
|
164
|
+
http.use_ssl = @uri.scheme == "https"
|
|
165
|
+
http.open_timeout = timeout
|
|
166
|
+
http.read_timeout = timeout
|
|
167
|
+
http.write_timeout = timeout
|
|
168
|
+
# O Net::HTTP repete sozinho GET/PUT/DELETE uma vez em certas falhas; quem decide
|
|
169
|
+
# novas tentativas (e conta) é esta classe.
|
|
170
|
+
http.max_retries = 0
|
|
171
|
+
# Requisição genérica, e não Net::HTTP::Post/Put: sem corpo, aquelas viram `body = ""` +
|
|
172
|
+
# `Content-Type: application/x-www-form-urlencoded` por conta própria.
|
|
173
|
+
req = Net::HTTPGenericRequest.new(method, !payload.nil?, true, target, headers)
|
|
174
|
+
if payload.nil?
|
|
175
|
+
req["Content-Length"] = "0" if BODY_METHODS.include?(method)
|
|
176
|
+
else
|
|
177
|
+
req.body = payload
|
|
178
|
+
end
|
|
179
|
+
response = http.start { |conn| conn.request(req) }
|
|
180
|
+
resp_headers = {}
|
|
181
|
+
response.each_header { |name, value| resp_headers[name] = value }
|
|
182
|
+
[response.code.to_i, resp_headers, response.body.to_s]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# 2xx → `[data, pagination]`. Sem envelope JSON válido → `INVALID_RESPONSE`.
|
|
186
|
+
#
|
|
187
|
+
# Nunca devolve `nil` calado: um proxy que responde 200 com HTML (ou um corpo vazio) vira
|
|
188
|
+
# erro, não "sucesso sem dados".
|
|
189
|
+
def unwrap(status, headers, raw, sent_request_id)
|
|
190
|
+
payload, text = Codec.decode_json(raw)
|
|
191
|
+
if payload.is_a?(Hash) && payload.key?("data")
|
|
192
|
+
pagination = payload["pagination"]
|
|
193
|
+
return [payload["data"], pagination.is_a?(Hash) ? pagination : nil]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
shown = text.strip[0, 200]
|
|
197
|
+
detail = shown.empty? ? " (corpo vazio)" : ": #{shown.inspect}"
|
|
198
|
+
raise Error.new(
|
|
199
|
+
"INVALID_RESPONSE (HTTP #{status}): resposta sem o envelope JSON da API#{detail}",
|
|
200
|
+
code: "INVALID_RESPONSE",
|
|
201
|
+
status: status,
|
|
202
|
+
request_id: present(headers["x-request-id"]) || sent_request_id,
|
|
203
|
+
body: payload.nil? ? present(text) : payload
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Resposta fora de 2xx → erro. `request_id`: corpo → header → o id que a SDK enviou.
|
|
208
|
+
def build_error(status, headers, raw, sent_request_id)
|
|
209
|
+
payload, text = Codec.decode_json(raw)
|
|
210
|
+
code = nil
|
|
211
|
+
human = nil
|
|
212
|
+
request_id = nil
|
|
213
|
+
validation = {}
|
|
214
|
+
|
|
215
|
+
if payload.is_a?(Hash)
|
|
216
|
+
err = payload["error"]
|
|
217
|
+
msg = payload["message"]
|
|
218
|
+
if err.is_a?(String) && !err.empty?
|
|
219
|
+
code = err
|
|
220
|
+
elsif msg.is_a?(String) && !msg.empty?
|
|
221
|
+
code = msg
|
|
222
|
+
end
|
|
223
|
+
human = msg if msg.is_a?(String) && !msg.empty? && msg != code
|
|
224
|
+
validation = payload["validation"] if payload["validation"].is_a?(Hash)
|
|
225
|
+
rid = payload["request_id"]
|
|
226
|
+
request_id = rid if rid.is_a?(String) && !rid.empty?
|
|
227
|
+
elsif !text.strip.empty?
|
|
228
|
+
human = text.strip[0, 200]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
code ||= "HTTP_#{status}"
|
|
232
|
+
request_id ||= present(headers["x-request-id"]) || sent_request_id
|
|
233
|
+
retry_after = status == 429 ? self.class.parse_retry_after(headers["retry-after"]) : nil
|
|
234
|
+
required_scope = status == 403 ? present(headers["x-required-scope"]) : nil
|
|
235
|
+
required_module = status == 403 ? present(headers["x-required-module"]) : nil
|
|
236
|
+
|
|
237
|
+
message = "#{code} (HTTP #{status})"
|
|
238
|
+
message += ": #{human}" if human
|
|
239
|
+
message += " — escopo exigido: #{required_scope}" if required_scope
|
|
240
|
+
message += " — módulo exigido: #{required_module}" if required_module
|
|
241
|
+
message += " — #{validation.map { |field, why| "#{field}: #{why}" }.join('; ')}" unless validation.empty?
|
|
242
|
+
message += " — tente de novo em #{retry_after}s" unless retry_after.nil?
|
|
243
|
+
|
|
244
|
+
Error.class_for(status).new(
|
|
245
|
+
message,
|
|
246
|
+
code: code,
|
|
247
|
+
status: status,
|
|
248
|
+
request_id: request_id,
|
|
249
|
+
validation: validation,
|
|
250
|
+
retry_after: retry_after,
|
|
251
|
+
required_scope: required_scope,
|
|
252
|
+
body: payload.nil? ? present(text) : payload
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def present(value)
|
|
257
|
+
text = value.to_s
|
|
258
|
+
text.empty? ? nil : text
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
data/lib/bfocus/unset.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bfocus
|
|
4
|
+
# Marca de argumento **não informado** — diferente de `nil`, que vai como `null` e LIMPA o
|
|
5
|
+
# campo na API. É o valor padrão dos keyword args opcionais de corpo; você nunca precisa
|
|
6
|
+
# usá-la. Existe uma única instância: {Bfocus::UNSET}.
|
|
7
|
+
class Unset
|
|
8
|
+
def inspect
|
|
9
|
+
"Bfocus::UNSET"
|
|
10
|
+
end
|
|
11
|
+
alias to_s inspect
|
|
12
|
+
|
|
13
|
+
# Continua sendo a mesma instância (comparação por identidade).
|
|
14
|
+
def dup
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Continua sendo a mesma instância (comparação por identidade).
|
|
19
|
+
def clone(freeze: true) # rubocop:disable Lint/UnusedMethodArgument
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Valor padrão dos keyword args opcionais de corpo: "não envie este campo".
|
|
25
|
+
UNSET = Unset.new.freeze
|
|
26
|
+
Unset.private_class_method :new
|
|
27
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bfocus
|
|
4
|
+
# Assina a identidade do usuário logado para abrir o widget de atendimento do bFocus.
|
|
5
|
+
#
|
|
6
|
+
# Roda no **seu backend** (o segredo nunca vai para o navegador) e não exige chave de API
|
|
7
|
+
# nem faz rede. Devolve o HMAC-SHA256, em hexadecimal minúsculo, de
|
|
8
|
+
# `"v1:" + user_external_id + ":" + customer_external_id` (UTF-8).
|
|
9
|
+
#
|
|
10
|
+
# @param secret [String] segredo de identidade do widget (painel do bFocus).
|
|
11
|
+
# @param user_external_id [String] `external_id` do usuário no seu sistema.
|
|
12
|
+
# @param customer_external_id [String] `external_id` do cliente (empresa) desse usuário.
|
|
13
|
+
# @return [String] 64 caracteres hexadecimais minúsculos.
|
|
14
|
+
# @raise [ArgumentError] segredo vazio ou id `nil`.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# Bfocus.sign_widget_identity(ENV.fetch("BFOCUS_WIDGET_SECRET"), "USR-1", "ERP 1042")
|
|
18
|
+
def self.sign_widget_identity(secret, user_external_id, customer_external_id)
|
|
19
|
+
raise ArgumentError, "sign_widget_identity: secret é obrigatório." if secret.nil? || secret.to_s.empty?
|
|
20
|
+
raise ArgumentError, "sign_widget_identity: user_external_id é obrigatório." if user_external_id.nil?
|
|
21
|
+
raise ArgumentError, "sign_widget_identity: customer_external_id é obrigatório." if customer_external_id.nil?
|
|
22
|
+
|
|
23
|
+
utf8 = lambda do |value|
|
|
24
|
+
text = value.to_s
|
|
25
|
+
(text.encoding == ::Encoding::BINARY ? text : text.encode(::Encoding::UTF_8)).b
|
|
26
|
+
end
|
|
27
|
+
message = "v1:".b + utf8.call(user_external_id) + ":".b + utf8.call(customer_external_id)
|
|
28
|
+
OpenSSL::HMAC.hexdigest("SHA256", utf8.call(secret), message)
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/bfocus.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SDK oficial em Ruby da API pública do bFocus: clientes, produtos, release notes, base de
|
|
4
|
+
# conhecimento e agentes de IA. Só biblioteca padrão (net/http, json, openssl, securerandom).
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# require "bfocus"
|
|
8
|
+
#
|
|
9
|
+
# client = Bfocus::Client.new(ENV.fetch("BFOCUS_API_KEY"))
|
|
10
|
+
# client.customers.upsert("ERP 1042", name: "Padaria Estrela")
|
|
11
|
+
module Bfocus
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require "json"
|
|
15
|
+
require "net/http"
|
|
16
|
+
require "openssl"
|
|
17
|
+
require "securerandom"
|
|
18
|
+
require "time"
|
|
19
|
+
require "uri"
|
|
20
|
+
|
|
21
|
+
require_relative "bfocus/version"
|
|
22
|
+
require_relative "bfocus/unset"
|
|
23
|
+
require_relative "bfocus/errors"
|
|
24
|
+
require_relative "bfocus/page"
|
|
25
|
+
require_relative "bfocus/codec"
|
|
26
|
+
require_relative "bfocus/transport"
|
|
27
|
+
require_relative "bfocus/resources/base"
|
|
28
|
+
require_relative "bfocus/resources/customers"
|
|
29
|
+
require_relative "bfocus/resources/products"
|
|
30
|
+
require_relative "bfocus/resources/release_notes"
|
|
31
|
+
require_relative "bfocus/resources/knowledge_base"
|
|
32
|
+
require_relative "bfocus/resources/ai_agents"
|
|
33
|
+
require_relative "bfocus/client"
|
|
34
|
+
require_relative "bfocus/widget"
|
metadata
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bfocus
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Berni Software
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-19 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Clientes, produtos, release notes, base de conhecimento e agentes de
|
|
14
|
+
IA do bFocus. Zero dependências de runtime, novas tentativas e idempotência automáticas.
|
|
15
|
+
email:
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/bfocus.rb
|
|
23
|
+
- lib/bfocus/client.rb
|
|
24
|
+
- lib/bfocus/codec.rb
|
|
25
|
+
- lib/bfocus/errors.rb
|
|
26
|
+
- lib/bfocus/page.rb
|
|
27
|
+
- lib/bfocus/resources/ai_agents.rb
|
|
28
|
+
- lib/bfocus/resources/base.rb
|
|
29
|
+
- lib/bfocus/resources/customers.rb
|
|
30
|
+
- lib/bfocus/resources/knowledge_base.rb
|
|
31
|
+
- lib/bfocus/resources/products.rb
|
|
32
|
+
- lib/bfocus/resources/release_notes.rb
|
|
33
|
+
- lib/bfocus/transport.rb
|
|
34
|
+
- lib/bfocus/unset.rb
|
|
35
|
+
- lib/bfocus/version.rb
|
|
36
|
+
- lib/bfocus/widget.rb
|
|
37
|
+
homepage: https://bfocus.com.br
|
|
38
|
+
licenses:
|
|
39
|
+
- MIT
|
|
40
|
+
metadata:
|
|
41
|
+
homepage_uri: https://bfocus.com.br
|
|
42
|
+
source_code_uri: https://github.com/bernisoftware/bfocus-ruby
|
|
43
|
+
bug_tracker_uri: https://github.com/bernisoftware/bfocus-ruby/issues
|
|
44
|
+
documentation_uri: https://github.com/bernisoftware/bfocus-ruby#readme
|
|
45
|
+
post_install_message:
|
|
46
|
+
rdoc_options: []
|
|
47
|
+
require_paths:
|
|
48
|
+
- lib
|
|
49
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.0'
|
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '0'
|
|
59
|
+
requirements: []
|
|
60
|
+
rubygems_version: 3.5.22
|
|
61
|
+
signing_key:
|
|
62
|
+
specification_version: 4
|
|
63
|
+
summary: SDK oficial da API pública do bFocus
|
|
64
|
+
test_files: []
|