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.
@@ -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
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bfocus
4
+ # Versão da gem. O `scripts/release-sdks.sh` do monorepo bumpa esta linha por regex e o
5
+ # `bfocus.gemspec` lê daqui — mantenha o formato exato `VERSION = "X.Y.Z"`.
6
+ VERSION = "0.1.0"
7
+ 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: []