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
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
|
data/lib/bfocus/codec.rb
ADDED
|
@@ -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
|