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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 427efa66516864dfd256464bb24bbda1dad9dae5d4c4df4da9b7e5f1c4cd7d7c
4
+ data.tar.gz: aff4271f81c03da6ffb70e16860e594f7e6428d582d79ea56fe31cfd783f472e
5
+ SHA512:
6
+ metadata.gz: 837084970aaf2a9ee8e4f383c74787021773a0057081ea60f8adf64512813a01abf860029d3333f5d0b624c540bfc3333367c6901f4677b8439f3b1c8d159505
7
+ data.tar.gz: f7a941f57940c96fc38c8d7b71dc97a545f4e8edf03d06b63c4b17da7a0c8cd6b8cc13b5220a5d1f4530fa38c2f021e88c41343831492aa11bb7a096a95ba7ab
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Berni Software
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,405 @@
1
+ # bfocus
2
+
3
+ SDK oficial em **Ruby** da API pública do [bFocus](https://bfocus.com.br): clientes, produtos,
4
+ release notes, base de conhecimento e agentes de IA.
5
+
6
+ Zero dependências de runtime (só biblioteca padrão: `net/http`, `json`, `openssl`,
7
+ `securerandom`) · Ruby 3.0+ · novas tentativas e idempotência automáticas.
8
+
9
+ ## Instalação
10
+
11
+ ```bash
12
+ gem install bfocus -v 0.1.0
13
+ ```
14
+
15
+ Ou no `Gemfile`:
16
+
17
+ ```ruby
18
+ gem "bfocus", "0.1.0"
19
+ ```
20
+
21
+ ## Hello world
22
+
23
+ ```ruby
24
+ require "bfocus"
25
+
26
+ client = Bfocus::Client.new("bf_live_...")
27
+
28
+ cliente = client.customers.upsert("ERP 1042", name: "Padaria Estrela", email: "contato@padaria.example")
29
+ puts cliente["id"], cliente["name"]
30
+ ```
31
+
32
+ `upsert` cria ou atualiza pelo `external_id` do **seu** sistema — rodar de novo não duplica.
33
+
34
+ ## Autenticação
35
+
36
+ Crie a chave no bFocus em **Integrações → Chaves de API**, marcando só os escopos de que a
37
+ integração precisa. Ela vai em `Authorization: Bearer <chave>` em toda requisição (a SDK cuida disso).
38
+
39
+ | Escopo | Permite |
40
+ | --- | --- |
41
+ | `customers:read` | Ler clientes, contatos, produtos vinculados e interações |
42
+ | `customers:write` | Cadastrar, atualizar e excluir clientes, contatos e interações |
43
+ | `products:read` | Ler o catálogo de produtos |
44
+ | `products:write` | Cadastrar, atualizar e arquivar produtos |
45
+ | `kb:read` | Ler e buscar artigos da base de conhecimento |
46
+ | `kb:write` | Criar, atualizar, publicar e excluir artigos da base de conhecimento |
47
+ | `ai_agents:read` | Ler os agentes de IA |
48
+ | `ai_agents:preview` | Testar a resposta de um agente de IA (consome IA da conta) |
49
+ | `release_notes:read` | Ler release notes |
50
+ | `release_notes:write` | Criar, atualizar e publicar release notes |
51
+
52
+ A chave legada (`bf_sk_…`) só alcança clientes (`customers:*`). A base de conhecimento e os
53
+ agentes de IA exigem o **módulo de Atendimento** contratado (sem ele a API responde
54
+ `MODULE_NOT_CONTRACTED` — veja [Erros](#erros)). Guarde a chave fora do código:
55
+
56
+ ```ruby
57
+ require "bfocus"
58
+
59
+ client = Bfocus::Client.new(
60
+ ENV.fetch("BFOCUS_API_KEY"),
61
+ base_url: "https://api.bfocus.com.br", # padrão; em dev: "http://localhost:8000"
62
+ timeout: 30, # segundos, por tentativa
63
+ max_retries: 2 # novas tentativas além da primeira (0 desliga)
64
+ )
65
+ ```
66
+
67
+ Construir o cliente não faz nenhuma chamada de rede. O cliente não guarda estado entre chamadas
68
+ (cada tentativa abre a própria conexão), então pode ser compartilhado entre threads.
69
+
70
+ ## Como os métodos funcionam
71
+
72
+ - **Retorno desembrulhado**: o método devolve o `data` da resposta como `Hash` (ou `Array` de
73
+ `Hash`) com **chaves string**, exatamente como a API devolve — `cliente["name"]`. Campos novos
74
+ que a API passar a devolver aparecem no Hash; nunca viram erro. Datas chegam como string ISO 8601
75
+ (use `Time.iso8601(...)` se precisar). Os campos de cada retorno estão em
76
+ [Formato dos retornos](#formato-dos-retornos).
77
+ - **Listas paginadas** (`customers.list`, `interactions.list`, `release_notes.list`,
78
+ `kb.articles.list`) devolvem `Bfocus::Page` (`items`, `page`, `page_size`, `total`, `pages`,
79
+ `next_page?`), que é `Enumerable`. Para percorrer tudo, use `list_all(...)`: um
80
+ `Enumerator::Lazy` que busca página por página só quando você consome (`page_size` padrão 100) e
81
+ para na última página ou numa página vazia.
82
+ - **Só o que você passa muda.** Os upserts são parciais: argumento não informado não é enviado;
83
+ `nil` explícito vai como `null` e **limpa** o campo.
84
+
85
+ ```ruby
86
+ client.customers.upsert("ERP 1042", phone: "11 3333-4444") # só o telefone muda
87
+ client.customers.upsert("ERP 1042", phone: nil) # apaga o telefone
88
+ ```
89
+
90
+ (Por baixo, o padrão dos keyword args opcionais é a sentinela `Bfocus::UNSET` — você nunca
91
+ precisa usá-la.)
92
+ - Obrigatórios são posicionais; opcionais são keyword args. Toda chamada aceita `timeout:`; as de
93
+ escrita aceitam `idempotency_key:` (veja [Novas tentativas](#novas-tentativas-e-idempotência)).
94
+ - Datas (`updated_since`) aceitam `Time`/`DateTime` — convertidos para ISO 8601 em UTC com `Z` —,
95
+ `Date` (meia-noite UTC) ou string, que passa como veio.
96
+ - Hashes de entrada (`custom_fields`, itens do `batch_upsert`, `history`) aceitam chaves símbolo
97
+ ou string.
98
+
99
+ ## Clientes
100
+
101
+ ```ruby
102
+ client.customers.upsert(
103
+ "ERP 1042",
104
+ name: "Padaria Estrela",
105
+ document: "12.345.678/0001-90",
106
+ custom_fields: [{ key: "plano", label: "Plano", value: "ouro" }] # substitui a lista
107
+ )
108
+
109
+ cliente = client.customers.get("ERP 1042")
110
+
111
+ pagina = client.customers.list(q: "padaria", page: 1, page_size: 50)
112
+ puts pagina.total, pagina.map { |c| c["name"] }.inspect
113
+
114
+ # Sincronização incremental: tudo o que mudou desde a última rodada, todas as páginas.
115
+ desde = Time.now - 3600
116
+ client.customers.list_all(updated_since: desde).each do |c|
117
+ puts "#{c['external_id']} #{c['updated_at']}"
118
+ end
119
+
120
+ client.customers.delete("ERP 1042")
121
+ ```
122
+
123
+ ### Contatos, produtos vinculados e interações
124
+
125
+ ```ruby
126
+ client.customers.contacts.upsert("ERP 1042", "CT-1", name: "Ana Souza", role: "Financeiro",
127
+ email: "ana@padaria.example", is_primary: true)
128
+ client.customers.contacts.list("ERP 1042")
129
+ client.customers.contacts.delete("ERP 1042", "CT-1")
130
+
131
+ client.customers.products.attach("ERP 1042", "erp-cloud")
132
+ client.customers.products.list("ERP 1042")
133
+ client.customers.products.detach("ERP 1042", "erp-cloud")
134
+
135
+ client.customers.interactions.create("ERP 1042", "Pedido 1042 faturado.",
136
+ author_email: "carla@suaempresa.com.br")
137
+ client.customers.interactions.list_all("ERP 1042").each do |i|
138
+ puts "#{i['created_at']} #{i['content']}"
139
+ end
140
+ ```
141
+
142
+ ## Produtos
143
+
144
+ ```ruby
145
+ client.products.upsert("erp-cloud", name: "ERP Cloud", description: "Gestão na nuvem", color: "#6366F1")
146
+ client.products.get("erp-cloud")
147
+ client.products.list(include_inactive: true)
148
+ client.products.archive("erp-cloud") # arquiva, não apaga
149
+ ```
150
+
151
+ ## Release notes — publicar direto do CI
152
+
153
+ Um passo no pipeline de release: cria ou atualiza a nota da versão e já publica.
154
+
155
+ ```ruby
156
+ # script/publicar_release_note.rb — roda no CI a cada tag
157
+ require "bfocus"
158
+
159
+ client = Bfocus::Client.new(ENV.fetch("BFOCUS_API_KEY")) # escopo release_notes:write
160
+ versao = ENV.fetch("GITHUB_REF_NAME") # "v2.3.0" — o "v" na frente é aceito
161
+
162
+ client.release_notes.upsert(
163
+ "erp-cloud",
164
+ versao,
165
+ title: "Versão #{versao.delete_prefix('v')}",
166
+ description_markdown: File.read("release-notes/#{versao}.md", encoding: "UTF-8"),
167
+ audience: "external", # "internal" | "external" | "both"
168
+ publish: true # cria/atualiza e publica numa chamada só
169
+ )
170
+ ```
171
+
172
+ Rodar de novo para a mesma versão atualiza a nota (é upsert). Também há:
173
+
174
+ ```ruby
175
+ client.release_notes.get("erp-cloud", "2.3.0")
176
+ client.release_notes.list("erp-cloud", published: false) # rascunhos (Page)
177
+ client.release_notes.list_all("erp-cloud").to_a
178
+ client.release_notes.publish("erp-cloud", "2.3.0")
179
+ ```
180
+
181
+ ## Base de conhecimento — sincronizar a partir de arquivos Markdown
182
+
183
+ Mantenha a documentação no repositório e sincronize a cada push. `batch_upsert` aceita **qualquer
184
+ quantidade** de artigos: a SDK divide em lotes de 100 (o limite da API), envia em sequência e
185
+ devolve um único resultado.
186
+
187
+ ```ruby
188
+ require "bfocus"
189
+
190
+ client = Bfocus::Client.new(ENV.fetch("BFOCUS_API_KEY")) # escopos kb:read e kb:write
191
+ docs = "docs"
192
+
193
+ artigos = Dir.glob("#{docs}/**/*.md").sort.map do |arquivo|
194
+ texto = File.read(arquivo, encoding: "UTF-8")
195
+ titulo = texto.lines.find { |l| l.start_with?("# ") }&.delete_prefix("# ")&.strip || File.basename(arquivo, ".md")
196
+ relativo = arquivo.delete_prefix("#{docs}/").delete_suffix(".md")
197
+ {
198
+ # id estável e SEM "/": o caminho do arquivo com ":" no lugar das barras.
199
+ # Aceita letras, números e . _ : ~ @ + = -
200
+ external_id: "git:#{relativo.tr('/', ':')}",
201
+ title: titulo,
202
+ body_markdown: texto,
203
+ product: "erp-cloud" # ou nil (explícito) para um artigo global
204
+ }
205
+ end
206
+
207
+ res = client.kb.articles.batch_upsert(artigos)
208
+ puts "#{res['created']} criados, #{res['updated']} atualizados, " \
209
+ "#{res['unchanged']} sem mudança, #{res['failed']} com falha"
210
+
211
+ res["results"].each do |r| # na mesma ordem enviada
212
+ if !r["ok"]
213
+ puts "falhou: #{r['external_id']} #{r['error']}" # ex.: KB_ARTICLE_TITLE_REQUIRED
214
+ elsif %w[created updated].include?(r["action"])
215
+ client.kb.articles.publish(r["external_id"]) # publica o que entrou ou mudou
216
+ end
217
+ end
218
+
219
+ # Remove do bFocus o que saiu do repositório.
220
+ locais = artigos.map { |a| a[:external_id] }
221
+ client.kb.articles.list_all(product: "erp-cloud").each do |artigo|
222
+ ext = artigo["external_id"].to_s
223
+ client.kb.articles.delete(ext) if ext.start_with?("git:") && !locais.include?(ext)
224
+ end
225
+ ```
226
+
227
+ Um item com problema não derruba os outros: ele volta com `"ok" => false` e o motivo em
228
+ `"error"`. Para publicar já no lote, mande `status: "published"` em cada item. Lista vazia devolve
229
+ o resultado zerado sem chamar a API. Artigo a artigo:
230
+
231
+ ```ruby
232
+ client.kb.articles.upsert("notion:emitir-nfse", title: "Como emitir NFS-e",
233
+ body_markdown: "# Passo a passo\n\n1. Abra o menu **Fiscal**",
234
+ product: nil, status: "published")
235
+ client.kb.articles.get("notion:emitir-nfse") # artigo completo, com body_html
236
+ client.kb.articles.list(status: "draft", q: "nota") # Page de resumos (sem body_html)
237
+ client.kb.articles.unpublish("notion:emitir-nfse")
238
+ client.kb.articles.delete("notion:emitir-nfse")
239
+ ```
240
+
241
+ ### Busca
242
+
243
+ ```ruby
244
+ client.kb.search("como emitir nota fiscal", product: "erp-cloud", limit: 3).each do |hit|
245
+ puts "#{hit['title']} — #{hit['excerpt']}"
246
+ end
247
+ ```
248
+
249
+ ## Agentes de IA
250
+
251
+ ```ruby
252
+ agentes = client.ai_agents.list
253
+ agente = client.ai_agents.get(agentes.first["id"])
254
+
255
+ resposta = client.ai_agents.preview(
256
+ agente["id"],
257
+ "Como emito uma NFS-e?",
258
+ history: [{ role: "customer", content: "Oi" },
259
+ { role: "bot", content: "Olá! Como posso ajudar?" }]
260
+ )
261
+ puts resposta["action"], resposta["answer_html"], resposta["sources"].inspect
262
+ ```
263
+
264
+ `preview` consome IA da conta (escopo `ai_agents:preview`).
265
+
266
+ ## Formato dos retornos
267
+
268
+ Todos são `Hash` com chaves string (campos novos podem aparecer a qualquer momento).
269
+
270
+ | Retorno (métodos) | Campos |
271
+ | --- | --- |
272
+ | Cliente (`customers.upsert`, `get`, `list`, `list_all`) | `id`, `external_id`, `name`, `document`, `email`, `phone`, `website`, `notes`, `custom_fields` (lista de `{key, label, type, value, visibility}`), `is_active`, `created_at`, `updated_at` |
273
+ | Contato (`customers.contacts.*`) | `id`, `external_id`, `name`, `role`, `email`, `phone`, `notes`, `is_primary`, `created_at`, `updated_at` |
274
+ | Produto vinculado (`customers.products.*`) | `id`, `slug`, `name`, `is_active` |
275
+ | Interação (`customers.interactions.*`) | `id`, `content`, `is_internal`, `author_kind`, `author_name`, `created_at` |
276
+ | Produto (`products.*`) | `id`, `slug`, `name`, `description`, `color`, `icon`, `is_active`, `sort_order`, `current_version`, `ai_level`, `created_at`, `updated_at` |
277
+ | Release note (`release_notes.*`) | `id`, `product`, `version`, `title`, `description_html`, `audience`, `is_published`, `require_ack_internal`, `require_ack_external`, `published_at`, `created_at`, `updated_at` |
278
+ | Artigo — resumo (`kb.articles.list`, `list_all`) | `id`, `external_id`, `product`, `title`, `excerpt`, `status`, `origin`, `published_at`, `created_at`, `updated_at` |
279
+ | Artigo completo (`kb.articles.get`, `upsert`, `publish`, `unpublish`) | o resumo + `body_html` |
280
+ | Lote (`kb.articles.batch_upsert`) | `results` (lista de `{external_id, ok, action, error, article}`; `action` ∈ `created`/`updated`/`unchanged`), `created`, `updated`, `unchanged`, `failed` |
281
+ | Resultado de busca (`kb.search`) | `id`, `external_id`, `title`, `excerpt` |
282
+ | Agente de IA (`ai_agents.list`, `get`) | `id`, `name`, `product` (`{id, slug, name, is_active}`), `active`, `persona`, `scope`, `avatar_url`, `created_at`, `updated_at` |
283
+ | Preview (`ai_agents.preview`) | `action` (`answer`/`handoff`/`refuse`), `answer_html`, `escalated`, `refused`, `handoff_reason`, `confidence`, `topic`, `guards`, `citations`, `sources`, `collected`, `missing` |
284
+ | Exclusão (`delete`, `detach`) | `deleted` |
285
+
286
+ ## Erros
287
+
288
+ Qualquer resposta fora de 2xx levanta `Bfocus::Error` (ou uma subclasse):
289
+
290
+ | Classe | Quando |
291
+ | --- | --- |
292
+ | `Bfocus::AuthenticationError` | 401 — chave ausente, inválida ou revogada |
293
+ | `Bfocus::PermissionDeniedError` | 403 — chave desligada, IP não liberado, escopo faltando (`required_scope`) ou módulo não contratado (`MODULE_NOT_CONTRACTED`) |
294
+ | `Bfocus::NotFoundError` | 404 |
295
+ | `Bfocus::ConflictError` | 409 — ex.: `KB_ARTICLE_EMPTY`, `AI_DISABLED` |
296
+ | `Bfocus::ValidationError` | 422 — motivos por campo em `validation` |
297
+ | `Bfocus::RateLimitError` | 429 — `retry_after` em segundos (depois de esgotar as novas tentativas) |
298
+ | `Bfocus::ServerError` | 5xx |
299
+ | `Bfocus::NetworkError` | conexão/timeout — `status == 0`, `code == "NETWORK_ERROR"` |
300
+
301
+ Todas têm `code`, `status`, `request_id`, `validation`, `retry_after`, `required_scope` e `body`.
302
+ **Decida pelo `code`** — ele é estável (`CUSTOMER_NOT_FOUND`, `INTEGRATION_SCOPE_MISSING`,
303
+ `MODULE_NOT_CONTRACTED`, `VALIDATION_ERROR`…). O `message` é texto para humanos e pode mudar. Ao
304
+ falar com o suporte, informe o `request_id`: ele vem do corpo da resposta, senão do header
305
+ `X-Request-Id`, senão é o id que a própria SDK enviou (a API ecoa o do cliente) — então está
306
+ sempre preenchido, inclusive em `NetworkError`.
307
+
308
+ Se uma resposta 2xx chegar sem o envelope JSON da API (um proxy devolvendo HTML, corpo vazio), a
309
+ SDK não devolve `nil` calado: levanta `Bfocus::Error` com `code == "INVALID_RESPONSE"` e o status
310
+ recebido. Corpo de erro que não é JSON vira `code == "HTTP_<status>"`.
311
+
312
+ ```ruby
313
+ begin
314
+ client.customers.get("ERP 9999")
315
+ rescue Bfocus::NotFoundError
316
+ puts "não existe"
317
+ rescue Bfocus::Error => e
318
+ case e.code
319
+ when "INTEGRATION_SCOPE_MISSING" then puts "a chave não tem o escopo #{e.required_scope}"
320
+ when "MODULE_NOT_CONTRACTED" then puts "contrate o módulo de Atendimento"
321
+ when "VALIDATION_ERROR" then p e.validation # {"email" => "value is not a valid email address"}
322
+ else puts "#{e.code} #{e.status} #{e.request_id}"
323
+ end
324
+ end
325
+ ```
326
+
327
+ Argumento inválido no seu código (chave vazia; parâmetro de caminho vazio, `"."` ou `".."`; `/`
328
+ no `external_id` de um artigo; item do lote sem `external_id`) levanta `ArgumentError`/`TypeError`
329
+ na hora, sem chamar a API — não é `Bfocus::Error`.
330
+
331
+ ## Novas tentativas e idempotência
332
+
333
+ A SDK tenta de novo sozinha em **erro de rede/timeout, 429, 502, 503 e 504** — até `max_retries`
334
+ vezes (padrão 2). Espera o `Retry-After` quando a API manda (segundos ou data HTTP; teto de 60 s);
335
+ senão 0,5 s, 1 s, 2 s… (teto de 8 s) + até 25% de variação aleatória. Um 500 ou outro 4xx volta na
336
+ hora.
337
+
338
+ Toda escrita (POST/PUT/DELETE) leva um `Idempotency-Key`, e **a mesma chave vai em todas as
339
+ tentativas** da chamada: se a primeira chegou a executar e só a resposta se perdeu, a API devolve a
340
+ resposta original (`Idempotent-Replayed: true`) em vez de executar de novo. O `X-Request-Id` também
341
+ se repete, para o suporte ver as tentativas como uma chamada só.
342
+
343
+ Para que a proteção valha também quando o **seu** processo roda de novo (um job reexecutado), passe
344
+ uma chave derivada do evento:
345
+
346
+ ```ruby
347
+ client.customers.interactions.create(
348
+ "ERP 1042", "Pedido 1042 faturado.",
349
+ idempotency_key: "pedido-1042-faturado"
350
+ )
351
+ ```
352
+
353
+ A mesma chave com outra requisição volta `IDEMPOTENCY_KEY_REUSED`. No `batch_upsert`, o 1º lote
354
+ usa a sua chave como veio e os seguintes `"<chave>:2"`, `"<chave>:3"`… (sem chave, cada lote gera
355
+ a sua).
356
+
357
+ Nos seus testes, troque a espera entre tentativas para não dormir:
358
+ `Bfocus::Client.new(chave, sleeper: ->(segundos) {})`.
359
+
360
+ ## Identidade do widget
361
+
362
+ Para o widget de atendimento reconhecer o usuário logado, o **seu backend** assina a identidade
363
+ dele com o segredo do widget (que nunca vai para o navegador). É local — sem rede e sem chave de API:
364
+
365
+ ```ruby
366
+ require "bfocus"
367
+
368
+ assinatura = Bfocus.sign_widget_identity(
369
+ ENV.fetch("BFOCUS_WIDGET_SECRET"),
370
+ "USR-1", # user_external_id: o usuário no seu sistema
371
+ "ERP 1042" # customer_external_id: a empresa (cliente) dele
372
+ )
373
+ # HMAC-SHA256 em hex minúsculo de "v1:USR-1:ERP 1042" — entregue junto dos dois ids à página
374
+ # que abre o widget.
375
+ ```
376
+
377
+ ## Versões
378
+
379
+ **Fixe a versão exata** (`gem "bfocus", "0.1.0"` no `Gemfile`) e suba de uma versão para a outra
380
+ de propósito. Cada release declara se muda a superfície pública (`additive` ou `breaking: …`),
381
+ então dá para saber o que revisar antes de subir.
382
+
383
+ A SDK se identifica em toda requisição (`X-Bfocus-Client: bfocus-ruby/<versão>`, também em
384
+ `Bfocus::CLIENT_ID`): quando uma correção exigir atualizar, o bFocus avisa as contas que rodam a
385
+ versão afetada.
386
+
387
+ ## Exemplo
388
+
389
+ Um script rodável está em [`examples/quickstart.rb`](examples/quickstart.rb):
390
+
391
+ ```bash
392
+ BFOCUS_API_KEY=bf_live_... ruby examples/quickstart.rb
393
+ ```
394
+
395
+ ## Desenvolvimento
396
+
397
+ ```bash
398
+ bundle install
399
+ bundle exec rake test # conformidade + unitários (servidor HTTP local, sem rede externa)
400
+ gem build bfocus.gemspec
401
+ ```
402
+
403
+ ## Licença
404
+
405
+ MIT © Berni Software
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bfocus
4
+ # Cliente da API pública do bFocus.
5
+ #
6
+ # Nada é chamado na rede ao construir. Pode ser compartilhado entre threads (cada tentativa
7
+ # abre a própria conexão).
8
+ #
9
+ # @example
10
+ # client = Bfocus::Client.new(ENV.fetch("BFOCUS_API_KEY"))
11
+ # client.customers.upsert("ERP 1042", name: "Padaria Estrela")
12
+ class Client
13
+ # @return [Resources::Customers] clientes (empresas), com `.contacts`, `.products` e
14
+ # `.interactions`.
15
+ attr_reader :customers
16
+ # @return [Resources::Products] catálogo de produtos.
17
+ attr_reader :products
18
+ # @return [Resources::ReleaseNotes] release notes por produto.
19
+ attr_reader :release_notes
20
+ # @return [Resources::KnowledgeBase] base de conhecimento: `.articles` e `.search(...)`.
21
+ attr_reader :kb
22
+ # @return [Resources::AIAgents] agentes de IA.
23
+ attr_reader :ai_agents
24
+
25
+ # @param api_key [String] chave de API (Integrações → Chaves de API). Único argumento
26
+ # posicional e obrigatório.
27
+ # @param base_url [String] URL da API, sem barra final. Padrão: produção
28
+ # (`https://api.bfocus.com.br`). Em dev: `http://localhost:8000`.
29
+ # @param timeout [Numeric] segundos por tentativa (padrão 30).
30
+ # @param max_retries [Integer] novas tentativas além da primeira em erro de rede/timeout,
31
+ # 429, 502, 503 e 504 (padrão 2; `0` desliga).
32
+ # @param sleeper [#call, nil] espera entre tentativas, chamada com os segundos (padrão
33
+ # `Kernel#sleep`). Serve para testes não dormirem de verdade.
34
+ # @raise [ArgumentError] chave vazia ou opção inválida.
35
+ # @raise [TypeError] chave que não é String.
36
+ def initialize(api_key, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
37
+ max_retries: DEFAULT_MAX_RETRIES, sleeper: nil)
38
+ raise TypeError, "Bfocus::Client: api_key precisa ser String (ex.: \"bf_live_...\")." unless api_key.is_a?(String)
39
+ raise ArgumentError, "Bfocus::Client: api_key é obrigatória (ex.: \"bf_live_...\")." if api_key.strip.empty?
40
+ unless max_retries.is_a?(Integer) && max_retries >= 0
41
+ raise ArgumentError, "Bfocus::Client: max_retries precisa ser um inteiro >= 0."
42
+ end
43
+ unless timeout.is_a?(Numeric) && timeout.positive?
44
+ raise ArgumentError, "Bfocus::Client: timeout precisa ser > 0 (segundos)."
45
+ end
46
+ if !sleeper.nil? && !sleeper.respond_to?(:call)
47
+ raise ArgumentError, "Bfocus::Client: sleeper precisa responder a #call(segundos)."
48
+ end
49
+
50
+ url = base_url.to_s.strip
51
+ url = DEFAULT_BASE_URL if url.empty?
52
+ @transport = Transport.new(api_key, base_url: url, timeout: timeout,
53
+ max_retries: max_retries, sleeper: sleeper)
54
+ @customers = Resources::Customers.new(@transport)
55
+ @products = Resources::Products.new(@transport)
56
+ @release_notes = Resources::ReleaseNotes.new(@transport)
57
+ @kb = Resources::KnowledgeBase.new(@transport)
58
+ @ai_agents = Resources::AIAgents.new(@transport)
59
+ @masked_key = api_key.length > 8 ? "#{api_key[0, 8]}…" : "…"
60
+ end
61
+
62
+ # @return [String] URL da API (sem barra final).
63
+ def base_url
64
+ @transport.base_url
65
+ end
66
+
67
+ # @return [Numeric] segundos por tentativa.
68
+ def timeout
69
+ @transport.timeout
70
+ end
71
+
72
+ # @return [Integer] novas tentativas além da primeira.
73
+ def max_retries
74
+ @transport.max_retries
75
+ end
76
+
77
+ def inspect
78
+ "#<Bfocus::Client api_key=#{@masked_key.inspect} base_url=#{base_url.inspect}>"
79
+ end
80
+ alias to_s inspect
81
+ end
82
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bfocus
4
+ # Codificação de caminho, query e corpo. Sem estado; nada aqui faz rede.
5
+ # @api private
6
+ module Codec
7
+ module_function
8
+
9
+ UNRESERVED = /[^A-Za-z0-9\-._~]/n.freeze
10
+
11
+ # Percent-encode de um valor inteiro (tudo fora de `A-Z a-z 0-9 - . _ ~`), em UTF-8.
12
+ def escape(value)
13
+ text = value.to_s
14
+ text = text.encode(::Encoding::UTF_8) unless text.encoding == ::Encoding::BINARY
15
+ text.b.gsub(UNRESERVED) { |byte| format("%%%02X", byte.ord) }.force_encoding(::Encoding::US_ASCII)
16
+ end
17
+
18
+ # Percent-encode de UM segmento de caminho (`ERP 1042` → `ERP%201042`).
19
+ #
20
+ # Vazio, `"."` e `".."` são recusados antes de qualquer requisição: o cliente HTTP (ou um
21
+ # proxy) resolveria `%2E%2E` como navegação de caminho e chamaria outra rota.
22
+ # @raise [ArgumentError]
23
+ def path_segment(value, name, allow_slash: true)
24
+ raise ArgumentError, "#{name} é obrigatório." if value.nil? || value.equal?(UNSET)
25
+
26
+ text = value.to_s
27
+ raise ArgumentError, "#{name} não pode ser vazio." if text.empty?
28
+ raise ArgumentError, "#{name} não pode ser #{text.inspect}." if [".", ".."].include?(text)
29
+ if !allow_slash && text.include?("/")
30
+ raise ArgumentError, "#{name} não aceita '/' (a API recusa) — use ':' para hierarquia: #{text.inspect}"
31
+ end
32
+
33
+ escape(text)
34
+ end
35
+
36
+ # `Time`/`DateTime` → ISO 8601 em UTC com `Z` (microssegundos só quando houver).
37
+ def iso_utc(time)
38
+ time = time.getutc
39
+ text = time.strftime("%Y-%m-%dT%H:%M:%S")
40
+ text += format(".%06d", time.usec) unless time.usec.zero?
41
+ "#{text}Z"
42
+ end
43
+
44
+ # Valor de query: booleanos como `true`/`false`, datas em ISO 8601 UTC com `Z`, string
45
+ # passa como veio.
46
+ def query_value(value)
47
+ case value
48
+ when true then "true"
49
+ when false then "false"
50
+ when Time then iso_utc(value)
51
+ else
52
+ if defined?(::DateTime) && value.is_a?(::DateTime)
53
+ iso_utc(value.to_time)
54
+ elsif defined?(::Date) && value.is_a?(::Date)
55
+ "#{value.strftime('%Y-%m-%d')}T00:00:00Z"
56
+ else
57
+ value.to_s
58
+ end
59
+ end
60
+ end
61
+
62
+ # `?a=1&b=2` com os pares informados (`nil` é omitido); `""` se não sobra nenhum.
63
+ def query_string(query)
64
+ return "" if query.nil? || query.empty?
65
+
66
+ pairs = query.each_with_object([]) do |(key, value), acc|
67
+ next if value.nil? || value.equal?(UNSET)
68
+
69
+ acc << "#{escape(key)}=#{escape(query_value(value))}"
70
+ end
71
+ pairs.empty? ? "" : "?#{pairs.join('&')}"
72
+ end
73
+
74
+ # Corpo só com o que o usuário informou: {UNSET} sai; `nil` fica e vira `null`.
75
+ def compact(fields)
76
+ fields.reject { |_key, value| value.equal?(UNSET) }
77
+ end
78
+
79
+ # Converte o corpo para tipos JSON: chaves viram string, `UNSET` em Hash some, datas viram
80
+ # ISO 8601 (instantes em UTC com `Z`).
81
+ def jsonable(value)
82
+ case value
83
+ when Hash
84
+ value.each_with_object({}) do |(key, item), out|
85
+ next if item.equal?(UNSET)
86
+
87
+ out[key.to_s] = jsonable(item)
88
+ end
89
+ when Array then value.map { |item| jsonable(item) }
90
+ when Time then iso_utc(value)
91
+ when Symbol then value.to_s
92
+ else
93
+ if defined?(::DateTime) && value.is_a?(::DateTime)
94
+ iso_utc(value.to_time)
95
+ elsif defined?(::Date) && value.is_a?(::Date)
96
+ value.strftime("%Y-%m-%d")
97
+ else
98
+ value
99
+ end
100
+ end
101
+ end
102
+
103
+ # Texto UTF-8 (bytes inválidos trocados) → `[json_ou_nil, texto]`.
104
+ def decode_json(raw)
105
+ text = raw.to_s.dup.force_encoding(::Encoding::UTF_8).scrub
106
+ return [nil, text] if text.strip.empty?
107
+
108
+ [JSON.parse(text), text]
109
+ rescue JSON::ParserError
110
+ [nil, text]
111
+ end
112
+ end
113
+ end