bfocus 0.1.0 → 0.2.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 +4 -4
- data/README.md +168 -4
- data/lib/bfocus/client.rb +4 -1
- data/lib/bfocus/resources/base.rb +42 -1
- data/lib/bfocus/resources/customers.rb +58 -1
- data/lib/bfocus/resources/people.rb +123 -0
- data/lib/bfocus/version.rb +1 -1
- data/lib/bfocus/widget.rb +49 -0
- data/lib/bfocus.rb +2 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6989b99e0788e360a77f8853de9a52f5e9628db20515a2ccbb9c57fd173e4c23
|
|
4
|
+
data.tar.gz: 3e3a8766862577d33ddf9e8bb3959b380fbd995f43e97688bd7f8bf05d3a06c2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 337c578cf5bde255220540a83297d298a89a08f97e31f72a274a25b7614c5c9b68b9cd782a95f39382ed3b858019d42a0dd16a4fc66483a281e6e43bce1e2b18
|
|
7
|
+
data.tar.gz: 5291a2a3e6b1cd5e6fd6bd7bf9d5dfb2e3abc12aa14cccc70b9fc1d2400eba5980e56edc62e2ccfa988acff36c3afb6bff13f9180cbb2e1c27f610bdc2c45716
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# bfocus
|
|
2
2
|
|
|
3
|
-
SDK oficial em **Ruby** da API pública do [bFocus](https://bfocus.com.br): clientes,
|
|
4
|
-
release notes, base de conhecimento e agentes de IA.
|
|
3
|
+
SDK oficial em **Ruby** da API pública do [bFocus](https://bfocus.com.br): clientes, pessoas,
|
|
4
|
+
produtos, release notes, base de conhecimento e agentes de IA.
|
|
5
5
|
|
|
6
6
|
Zero dependências de runtime (só biblioteca padrão: `net/http`, `json`, `openssl`,
|
|
7
7
|
`securerandom`) · Ruby 3.0+ · novas tentativas e idempotência automáticas.
|
|
@@ -93,8 +93,8 @@ Construir o cliente não faz nenhuma chamada de rede. O cliente não guarda esta
|
|
|
93
93
|
escrita aceitam `idempotency_key:` (veja [Novas tentativas](#novas-tentativas-e-idempotência)).
|
|
94
94
|
- Datas (`updated_since`) aceitam `Time`/`DateTime` — convertidos para ISO 8601 em UTC com `Z` —,
|
|
95
95
|
`Date` (meia-noite UTC) ou string, que passa como veio.
|
|
96
|
-
- Hashes de entrada (`custom_fields`, itens do `batch_upsert`,
|
|
97
|
-
ou string.
|
|
96
|
+
- Hashes de entrada (`custom_fields`, itens do `batch_upsert`/`customers.batch`/`people.batch`,
|
|
97
|
+
`history`) aceitam chaves símbolo ou string.
|
|
98
98
|
|
|
99
99
|
## Clientes
|
|
100
100
|
|
|
@@ -139,6 +139,143 @@ client.customers.interactions.list_all("ERP 1042").each do |i|
|
|
|
139
139
|
end
|
|
140
140
|
```
|
|
141
141
|
|
|
142
|
+
### Identificadores extras
|
|
143
|
+
|
|
144
|
+
Ligue o id de **outro sistema seu** (CRM, loja, app…) ao mesmo cadastro: depois disso o cliente (ou
|
|
145
|
+
a pessoa) é encontrado por qualquer um dos ids. É idempotente (ligar de novo não muda nada). Se o id
|
|
146
|
+
já pertence a outro cadastro, a API recusa com `Bfocus::ConflictError` e `code == "IDENTIFIER_IN_USE"`.
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
cliente = client.customers.identifiers.add("erp-1042", "crm-88", label: "CRM") # label é opcional
|
|
150
|
+
cliente["identifiers"] # => [{"external_id" => "crm-88", "label" => "CRM", "source" => "api"}]
|
|
151
|
+
client.customers.identifiers.remove("erp-1042", "crm-88")
|
|
152
|
+
|
|
153
|
+
client.people.identifiers.add("app-77", "crm-p5")
|
|
154
|
+
client.people.identifiers.remove("app-77", "crm-p5")
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Pessoas
|
|
158
|
+
|
|
159
|
+
As pessoas (usuários do seu sistema) de cada cliente, em `client.people`. O `external_id` da pessoa
|
|
160
|
+
é o mesmo `user_external_id` que você assina para o [widget](#identidade-do-widget).
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
pessoa = client.people.upsert(
|
|
164
|
+
"erp-1042", "app-77",
|
|
165
|
+
name: "Paula Reis", email: "paula@padaria.example", role: "Financeiro", is_primary: true,
|
|
166
|
+
extra_emails: ["paula.reis@pessoal.example"]
|
|
167
|
+
)
|
|
168
|
+
pessoa["status"] # => "created", "updated" ou "unchanged"
|
|
169
|
+
|
|
170
|
+
client.people.list("erp-1042") # todas as pessoas do cliente
|
|
171
|
+
client.people.delete("erp-1042", "app-77") # retira o acesso; a pessoa continua no histórico
|
|
172
|
+
client.people.upsert("erp-1042", "app-77", access: true) # devolve o acesso
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
- **Nunca duplica.** O e-mail (ou o telefone) acha a pessoa que já chegou por e-mail, pelo widget
|
|
176
|
+
ou por outro sistema, e ela é **adotada** (ganha o seu `external_id`).
|
|
177
|
+
- A mesma pessoa enviada com **outro cliente** é **transferida** para ele.
|
|
178
|
+
- Como no resto da SDK, só o que você passa muda; `nil` limpa (`phone: nil`).
|
|
179
|
+
- Campos: `name`, `email`, `phone`, `role`, `access` (pode usar o atendimento), `is_primary`
|
|
180
|
+
(contato principal), `extra_emails`, `extra_phones`.
|
|
181
|
+
|
|
182
|
+
## Lotes — `customers.batch` e `people.batch`
|
|
183
|
+
|
|
184
|
+
Até **500 itens por chamada** (`Bfocus::BATCH_MAX`). Acima disso a SDK lança `ArgumentError` antes
|
|
185
|
+
de qualquer requisição — ela não divide sozinha, porque o `index` de cada resultado é a posição no
|
|
186
|
+
lote que **você** enviou. Divida assim:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
clientes = meus_clientes.map do |c|
|
|
190
|
+
{ external_id: "erp-#{c.id}", name: c.nome, document: c.cnpj, email: c.email }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
clientes.each_slice(Bfocus::BATCH_MAX) do |fatia|
|
|
194
|
+
resultado = client.customers.batch(fatia)
|
|
195
|
+
resultado["results"].each do |item|
|
|
196
|
+
next unless item["status"] == "error"
|
|
197
|
+
|
|
198
|
+
warn "#{fatia[item['index']][:external_id]}: #{item['error']} (HTTP #{item['code']})"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
- Cada item de `customers.batch` é `external_id` + os campos do `customers.upsert`.
|
|
204
|
+
- Cada item de `people.batch` é **plano**: `customer_external_id` + `external_id` (da pessoa) + os
|
|
205
|
+
campos do `people.upsert`.
|
|
206
|
+
- Retorno: `results` (um por item: `index`, `status` — `created`/`updated`/`unchanged`/`error` —,
|
|
207
|
+
`external_id`, `merged_into`, `error` com o código estável e `code` com o status HTTP do item) +
|
|
208
|
+
`summary` (`created`, `updated`, `unchanged`, `error`).
|
|
209
|
+
- **Um erro não desfaz os outros**: confira `summary["error"]` e registre os itens com erro.
|
|
210
|
+
- Lista vazia devolve o resultado zerado sem fazer requisição.
|
|
211
|
+
- `idempotency_key:` vale para o lote inteiro (um lote = uma chamada).
|
|
212
|
+
|
|
213
|
+
## Sincronizar clientes e usuários do seu sistema
|
|
214
|
+
|
|
215
|
+
**Ids com o prefixo do sistema, sem `:`.** Use `-` como separador — `erp-1042` para clientes,
|
|
216
|
+
`app-77` para pessoas — ou UUIDs puros: vários sistemas seus convivem no mesmo bFocus sem colisão. A
|
|
217
|
+
assinatura do widget recusa `:` (é o separador dela), então não use `:` em nenhum `external_id` de
|
|
218
|
+
cliente ou pessoa.
|
|
219
|
+
|
|
220
|
+
**Carga inicial (no deploy):** clientes em fatias de 500 → vincule cada um ao produto → pessoas em
|
|
221
|
+
fatias de 500.
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
def carga_inicial(client, clientes, usuarios)
|
|
225
|
+
clientes.each_slice(Bfocus::BATCH_MAX) do |fatia|
|
|
226
|
+
r = client.customers.batch(fatia.map { |c| { external_id: "erp-#{c.id}", name: c.nome } })
|
|
227
|
+
registrar_erros(r, fatia) if r["summary"]["error"].positive?
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
clientes.each { |c| client.customers.products.attach("erp-#{c.id}", "erp-cloud") }
|
|
231
|
+
|
|
232
|
+
usuarios.each_slice(Bfocus::BATCH_MAX) do |fatia|
|
|
233
|
+
itens = fatia.map do |u|
|
|
234
|
+
{ customer_external_id: "erp-#{u.cliente_id}", external_id: "app-#{u.id}",
|
|
235
|
+
name: u.nome, email: u.email }
|
|
236
|
+
end
|
|
237
|
+
r = client.people.batch(itens)
|
|
238
|
+
registrar_erros(r, fatia) if r["summary"]["error"].positive?
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Depois, no dia a dia**, espelhe cada evento do seu sistema:
|
|
244
|
+
|
|
245
|
+
| No seu sistema | No bFocus |
|
|
246
|
+
| --- | --- |
|
|
247
|
+
| criou/alterou cliente | `customers.upsert` |
|
|
248
|
+
| criou/alterou usuário | `people.upsert` |
|
|
249
|
+
| excluiu/desativou usuário | `people.delete` |
|
|
250
|
+
| excluiu cliente | `customers.delete` |
|
|
251
|
+
|
|
252
|
+
Vincule o cliente ao produto com `customers.products.attach`. Se a resposta trouxer `merged_into`,
|
|
253
|
+
o cadastro foi unificado em outro: atualize o id do seu lado.
|
|
254
|
+
|
|
255
|
+
**Nunca bloqueie a requisição do seu usuário esperando o bFocus.** Enfileire (job/outbox) e tente de
|
|
256
|
+
novo com backoff; a SDK já repete 429/5xx com a mesma `Idempotency-Key`, e a fila cobre
|
|
257
|
+
indisponibilidades longas.
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
# app/jobs/bfocus_sync_usuario_job.rb (ActiveJob; o mesmo vale para Sidekiq etc.)
|
|
261
|
+
class BfocusSyncUsuarioJob < ApplicationJob
|
|
262
|
+
retry_on Bfocus::NetworkError, Bfocus::RateLimitError, Bfocus::ServerError,
|
|
263
|
+
wait: :polynomially_longer, attempts: 10
|
|
264
|
+
|
|
265
|
+
def perform(usuario_id)
|
|
266
|
+
u = Usuario.find(usuario_id)
|
|
267
|
+
if u.ativo?
|
|
268
|
+
BFOCUS.people.upsert("erp-#{u.cliente_id}", "app-#{u.id}", name: u.nome, email: u.email)
|
|
269
|
+
else
|
|
270
|
+
BFOCUS.people.delete("erp-#{u.cliente_id}", "app-#{u.id}")
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# no model, depois de salvar — a requisição do usuário não espera o bFocus:
|
|
276
|
+
after_commit { BfocusSyncUsuarioJob.perform_later(id) }
|
|
277
|
+
```
|
|
278
|
+
|
|
142
279
|
## Produtos
|
|
143
280
|
|
|
144
281
|
```ruby
|
|
@@ -273,6 +410,11 @@ Todos são `Hash` com chaves string (campos novos podem aparecer a qualquer mome
|
|
|
273
410
|
| Contato (`customers.contacts.*`) | `id`, `external_id`, `name`, `role`, `email`, `phone`, `notes`, `is_primary`, `created_at`, `updated_at` |
|
|
274
411
|
| Produto vinculado (`customers.products.*`) | `id`, `slug`, `name`, `is_active` |
|
|
275
412
|
| Interação (`customers.interactions.*`) | `id`, `content`, `is_internal`, `author_kind`, `author_name`, `created_at` |
|
|
413
|
+
| Pessoa (`people.list`, `delete`) | `external_id` (pode ser `nil`), `name`, `email`, `phone`, `role`, `access`, `is_primary`, `customer_external_id` |
|
|
414
|
+
| Pessoa gravada (`people.upsert`) | a pessoa + `status` (`created`/`updated`/`unchanged`) |
|
|
415
|
+
| Cliente com identificadores (`customers.identifiers.add`, `remove`) | o cliente + `identifiers` (lista de `{external_id, label, source}`) |
|
|
416
|
+
| Identificadores da pessoa (`people.identifiers.add`, `remove`) | `external_id`, `identifiers` (lista de `{external_id, label, source}`) |
|
|
417
|
+
| Lote (`customers.batch`, `people.batch`) | `results` (lista de `{index, status, external_id, merged_into, error, code}`; `status` ∈ `created`/`updated`/`unchanged`/`error`), `summary` (`{created, updated, unchanged, error}`) |
|
|
276
418
|
| Produto (`products.*`) | `id`, `slug`, `name`, `description`, `color`, `icon`, `is_active`, `sort_order`, `current_version`, `ai_level`, `created_at`, `updated_at` |
|
|
277
419
|
| 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
420
|
| Artigo — resumo (`kb.articles.list`, `list_all`) | `id`, `external_id`, `product`, `title`, `excerpt`, `status`, `origin`, `published_at`, `created_at`, `updated_at` |
|
|
@@ -374,6 +516,28 @@ assinatura = Bfocus.sign_widget_identity(
|
|
|
374
516
|
# que abre o widget.
|
|
375
517
|
```
|
|
376
518
|
|
|
519
|
+
### Identidade do widget v2 (com validade)
|
|
520
|
+
|
|
521
|
+
A v2 carimba o instante na assinatura, então uma assinatura vazada deixa de valer sozinha:
|
|
522
|
+
|
|
523
|
+
```ruby
|
|
524
|
+
user_hash = Bfocus.sign_widget_identity_v2(
|
|
525
|
+
ENV.fetch("BFOCUS_WIDGET_SECRET"),
|
|
526
|
+
"app-77", # user_external_id: sem ":" (é o separador; a API recusa)
|
|
527
|
+
"erp-1042" # customer_external_id: a empresa dele
|
|
528
|
+
)
|
|
529
|
+
# => "v2.<ts>.<hex>", ex.: "v2.1789000000.9c1e…"
|
|
530
|
+
|
|
531
|
+
# instante explícito (segundos unix, não ms; ou Time) — útil em testes:
|
|
532
|
+
Bfocus.sign_widget_identity_v2(segredo, "app-77", "erp-1042", now: 1_789_000_000)
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
- `hex` = HMAC-SHA256 em hex minúsculo de `"v2:<ts>:<user_external_id>:<customer_external_id>"`.
|
|
536
|
+
- Vale de **7 dias atrás até 5 minutos à frente**: gere a cada renderização da página, **nunca
|
|
537
|
+
guarde**. Vai no mesmo lugar da v1 (`userHash` do widget).
|
|
538
|
+
- O id do usuário não pode ter `:` (`ArgumentError`) — use `-` como separador (`app-77`).
|
|
539
|
+
- A v1 continua aceita.
|
|
540
|
+
|
|
377
541
|
## Versões
|
|
378
542
|
|
|
379
543
|
**Fixe a versão exata** (`gem "bfocus", "0.1.0"` no `Gemfile`) e suba de uma versão para a outra
|
data/lib/bfocus/client.rb
CHANGED
|
@@ -11,8 +11,10 @@ module Bfocus
|
|
|
11
11
|
# client.customers.upsert("ERP 1042", name: "Padaria Estrela")
|
|
12
12
|
class Client
|
|
13
13
|
# @return [Resources::Customers] clientes (empresas), com `.contacts`, `.products` e
|
|
14
|
-
# `.interactions`.
|
|
14
|
+
# `.interactions` e `.identifiers`.
|
|
15
15
|
attr_reader :customers
|
|
16
|
+
# @return [Resources::People] pessoas (usuários) dos clientes, com `.identifiers`.
|
|
17
|
+
attr_reader :people
|
|
16
18
|
# @return [Resources::Products] catálogo de produtos.
|
|
17
19
|
attr_reader :products
|
|
18
20
|
# @return [Resources::ReleaseNotes] release notes por produto.
|
|
@@ -52,6 +54,7 @@ module Bfocus
|
|
|
52
54
|
@transport = Transport.new(api_key, base_url: url, timeout: timeout,
|
|
53
55
|
max_retries: max_retries, sleeper: sleeper)
|
|
54
56
|
@customers = Resources::Customers.new(@transport)
|
|
57
|
+
@people = Resources::People.new(@transport)
|
|
55
58
|
@products = Resources::Products.new(@transport)
|
|
56
59
|
@release_notes = Resources::ReleaseNotes.new(@transport)
|
|
57
60
|
@kb = Resources::KnowledgeBase.new(@transport)
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Bfocus
|
|
4
|
-
#
|
|
4
|
+
# Máximo de itens por chamada de `customers.batch` e `people.batch`. Acima disso a SDK lança
|
|
5
|
+
# `ArgumentError` antes de qualquer requisição — ela NÃO divide sozinha, porque o `"index"` de
|
|
6
|
+
# cada resultado é a posição no lote que você enviou. Divida com `each_slice(Bfocus::BATCH_MAX)`.
|
|
7
|
+
BATCH_MAX = 500
|
|
8
|
+
|
|
9
|
+
# Recursos da API pública: `customers`, `people`, `products`, `release_notes`, `kb`, `ai_agents`.
|
|
5
10
|
#
|
|
6
11
|
# Convenções (iguais em todos os métodos):
|
|
7
12
|
#
|
|
@@ -61,6 +66,42 @@ module Bfocus
|
|
|
61
66
|
def compact(fields)
|
|
62
67
|
Codec.compact(fields)
|
|
63
68
|
end
|
|
69
|
+
|
|
70
|
+
# Resultado de um lote vazio (sem requisição).
|
|
71
|
+
def empty_batch_result
|
|
72
|
+
{ "results" => [], "summary" => { "created" => 0, "updated" => 0, "unchanged" => 0, "error" => 0 } }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Valida a lista de um lote (`customers.batch`/`people.batch`) antes de qualquer
|
|
76
|
+
# requisição e devolve os itens como Hash de chaves string (sem {UNSET}).
|
|
77
|
+
# @raise [TypeError] não é lista ou item não é Hash.
|
|
78
|
+
# @raise [ArgumentError] mais de {Bfocus::BATCH_MAX} itens.
|
|
79
|
+
def batch_items(items, op)
|
|
80
|
+
if items.is_a?(Hash) || !items.respond_to?(:each_with_index) || !items.respond_to?(:size)
|
|
81
|
+
raise TypeError, "#{op}: items precisa ser uma lista de Hash."
|
|
82
|
+
end
|
|
83
|
+
if items.size > BATCH_MAX
|
|
84
|
+
raise ArgumentError, "#{op} aceita até #{BATCH_MAX} itens por chamada (recebeu #{items.size}); " \
|
|
85
|
+
"divida em lotes de #{BATCH_MAX}."
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
items.each_with_index.map do |item, index|
|
|
89
|
+
raise TypeError, "#{op}: items[#{index}] precisa ser um Hash." unless item.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
item.each_with_object({}) do |(key, value), out|
|
|
92
|
+
out[key.to_s] = value unless value.equal?(UNSET)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Campo de id obrigatório num item de lote: String (ou número) não vazia.
|
|
98
|
+
# @raise [ArgumentError]
|
|
99
|
+
def batch_id!(item, field, op, index)
|
|
100
|
+
value = item[field]
|
|
101
|
+
return value unless value.nil? || value.to_s.empty?
|
|
102
|
+
|
|
103
|
+
raise ArgumentError, "#{op}: items[#{index}].#{field} é obrigatório."
|
|
104
|
+
end
|
|
64
105
|
end
|
|
65
106
|
end
|
|
66
107
|
end
|
|
@@ -101,8 +101,37 @@ module Bfocus
|
|
|
101
101
|
end
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
+
# Identificadores extras de um cliente — `client.customers.identifiers`: liga o id de OUTRO
|
|
105
|
+
# sistema seu (CRM, loja…) ao mesmo cadastro, que passa a ser encontrado por qualquer um deles.
|
|
106
|
+
#
|
|
107
|
+
# Retorno: o cliente + `"identifiers"` (lista de `{"external_id", "label", "source"}`).
|
|
108
|
+
# Id que já pertence a outro cadastro: `ConflictError` com `code == "IDENTIFIER_IN_USE"`.
|
|
109
|
+
class CustomerIdentifiers < Base
|
|
110
|
+
# Liga `extra_id` ao cliente (idempotente).
|
|
111
|
+
# `PUT /customers/{external_id}/identifiers/{extra_id}`
|
|
112
|
+
#
|
|
113
|
+
# @param label [String, nil] rótulo livre (ex.: `"CRM"`). Não informado = sem corpo.
|
|
114
|
+
# @return [Hash] o cliente com `"identifiers"`.
|
|
115
|
+
def add(external_id, extra_id, label: UNSET, idempotency_key: nil, timeout: nil)
|
|
116
|
+
ext = segment(external_id, "external_id")
|
|
117
|
+
extra = segment(extra_id, "extra_id")
|
|
118
|
+
body = label.equal?(UNSET) ? nil : { "label" => label }
|
|
119
|
+
call("PUT", "/customers/#{ext}/identifiers/#{extra}",
|
|
120
|
+
body: body, idempotency_key: idempotency_key, timeout: timeout)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Desliga `extra_id` do cliente. `DELETE /customers/{external_id}/identifiers/{extra_id}`
|
|
124
|
+
# @return [Hash] o cliente com `"identifiers"`.
|
|
125
|
+
def remove(external_id, extra_id, idempotency_key: nil, timeout: nil)
|
|
126
|
+
ext = segment(external_id, "external_id")
|
|
127
|
+
extra = segment(extra_id, "extra_id")
|
|
128
|
+
call("DELETE", "/customers/#{ext}/identifiers/#{extra}",
|
|
129
|
+
idempotency_key: idempotency_key, timeout: timeout)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
104
133
|
# Clientes (empresas) — `client.customers`. Sub-recursos: {#contacts}, {#products},
|
|
105
|
-
# {#interactions}.
|
|
134
|
+
# {#interactions}, {#identifiers}.
|
|
106
135
|
#
|
|
107
136
|
# Cliente: `"id"`, `"external_id"`, `"name"`, `"document"`, `"email"`, `"phone"`,
|
|
108
137
|
# `"website"`, `"notes"`, `"custom_fields"` (lista de `{"key", "label", "type", "value",
|
|
@@ -114,12 +143,15 @@ module Bfocus
|
|
|
114
143
|
attr_reader :products
|
|
115
144
|
# @return [CustomerInteractions]
|
|
116
145
|
attr_reader :interactions
|
|
146
|
+
# @return [CustomerIdentifiers]
|
|
147
|
+
attr_reader :identifiers
|
|
117
148
|
|
|
118
149
|
def initialize(transport)
|
|
119
150
|
super
|
|
120
151
|
@contacts = CustomerContacts.new(transport)
|
|
121
152
|
@products = CustomerProducts.new(transport)
|
|
122
153
|
@interactions = CustomerInteractions.new(transport)
|
|
154
|
+
@identifiers = CustomerIdentifiers.new(transport)
|
|
123
155
|
end
|
|
124
156
|
|
|
125
157
|
# Cria ou atualiza um cliente pelo `external_id` do seu sistema. `PUT /customers/{external_id}`
|
|
@@ -137,6 +169,31 @@ module Bfocus
|
|
|
137
169
|
call("PUT", "/customers/#{ext}", body: body, idempotency_key: idempotency_key, timeout: timeout)
|
|
138
170
|
end
|
|
139
171
|
|
|
172
|
+
# Cria/atualiza até {Bfocus::BATCH_MAX} (500) clientes numa chamada. `POST /customers/batch`
|
|
173
|
+
#
|
|
174
|
+
# Cada item (Hash, chaves string ou símbolo) tem `external_id` (obrigatório) e os mesmos
|
|
175
|
+
# campos do {#upsert}, com a mesma regra: ausente = não muda; `nil` limpa.
|
|
176
|
+
#
|
|
177
|
+
# A SDK **não divide** o lote: mais de 500 itens lança `ArgumentError` antes de qualquer
|
|
178
|
+
# requisição (use `items.each_slice(Bfocus::BATCH_MAX)`); o `"index"` de cada resultado é
|
|
179
|
+
# a posição no lote enviado. Um item com erro não desfaz os outros. Lista vazia devolve o
|
|
180
|
+
# resultado zerado sem chamar a API.
|
|
181
|
+
#
|
|
182
|
+
# @return [Hash] `{"results" => [{"index", "status", "external_id", "merged_into", "error",
|
|
183
|
+
# "code"}, …], "summary" => {"created", "updated", "unchanged", "error"}}` —
|
|
184
|
+
# `"status"` ∈ `created`/`updated`/`unchanged`/`error`; `"error"` é o código estável e
|
|
185
|
+
# `"code"` o status HTTP do item.
|
|
186
|
+
# @raise [ArgumentError] mais de 500 itens ou item sem `external_id`.
|
|
187
|
+
# @raise [TypeError] `items` não é lista de Hash.
|
|
188
|
+
def batch(items, idempotency_key: nil, timeout: nil)
|
|
189
|
+
list = batch_items(items, "customers.batch")
|
|
190
|
+
return empty_batch_result if list.empty?
|
|
191
|
+
|
|
192
|
+
list.each_with_index { |item, index| batch_id!(item, "external_id", "customers.batch", index) }
|
|
193
|
+
call("POST", "/customers/batch",
|
|
194
|
+
body: { "items" => list }, idempotency_key: idempotency_key, timeout: timeout)
|
|
195
|
+
end
|
|
196
|
+
|
|
140
197
|
# Um cliente. `GET /customers/{external_id}`
|
|
141
198
|
# @return [Hash]
|
|
142
199
|
def get(external_id, timeout: nil)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bfocus
|
|
4
|
+
module Resources
|
|
5
|
+
# Identificadores extras de uma pessoa — `client.people.identifiers`: liga o id de OUTRO
|
|
6
|
+
# sistema seu ao mesmo cadastro.
|
|
7
|
+
#
|
|
8
|
+
# Retorno: `{"external_id", "identifiers" => [{"external_id", "label", "source"}, …]}`.
|
|
9
|
+
# Id que já pertence a outro cadastro: `ConflictError` com `code == "IDENTIFIER_IN_USE"`.
|
|
10
|
+
class PersonIdentifiers < Base
|
|
11
|
+
# Liga `extra_id` à pessoa (idempotente). `PUT /people/{person_external_id}/identifiers/{extra_id}`
|
|
12
|
+
#
|
|
13
|
+
# @param label [String, nil] rótulo livre. Não informado = sem corpo.
|
|
14
|
+
# @return [Hash]
|
|
15
|
+
def add(person_external_id, extra_id, label: UNSET, idempotency_key: nil, timeout: nil)
|
|
16
|
+
pid = segment(person_external_id, "person_external_id")
|
|
17
|
+
extra = segment(extra_id, "extra_id")
|
|
18
|
+
body = label.equal?(UNSET) ? nil : { "label" => label }
|
|
19
|
+
call("PUT", "/people/#{pid}/identifiers/#{extra}",
|
|
20
|
+
body: body, idempotency_key: idempotency_key, timeout: timeout)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Desliga `extra_id` da pessoa. `DELETE /people/{person_external_id}/identifiers/{extra_id}`
|
|
24
|
+
# @return [Hash]
|
|
25
|
+
def remove(person_external_id, extra_id, idempotency_key: nil, timeout: nil)
|
|
26
|
+
pid = segment(person_external_id, "person_external_id")
|
|
27
|
+
extra = segment(extra_id, "extra_id")
|
|
28
|
+
call("DELETE", "/people/#{pid}/identifiers/#{extra}",
|
|
29
|
+
idempotency_key: idempotency_key, timeout: timeout)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Pessoas (usuários do seu sistema) de um cliente — `client.people`. Sub-recurso:
|
|
34
|
+
# {#identifiers}.
|
|
35
|
+
#
|
|
36
|
+
# Pessoa: `"external_id"` (pode ser `nil` para quem chegou por e-mail/widget sem id),
|
|
37
|
+
# `"name"`, `"email"`, `"phone"`, `"role"`, `"access"`, `"is_primary"`,
|
|
38
|
+
# `"customer_external_id"`. O `upsert` devolve também `"status"`
|
|
39
|
+
# (`"created"`/`"updated"`/`"unchanged"`).
|
|
40
|
+
#
|
|
41
|
+
# O `external_id` da pessoa é o mesmo `user_external_id` assinado no widget — por isso não
|
|
42
|
+
# pode ter `:` (use outro separador, ex.: `"app-77"`).
|
|
43
|
+
class People < Base
|
|
44
|
+
# @return [PersonIdentifiers]
|
|
45
|
+
attr_reader :identifiers
|
|
46
|
+
|
|
47
|
+
def initialize(transport)
|
|
48
|
+
super
|
|
49
|
+
@identifiers = PersonIdentifiers.new(transport)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Cria ou atualiza uma pessoa do cliente pelo `external_id` dela.
|
|
53
|
+
# `PUT /customers/{customer_external_id}/people/{person_external_id}`
|
|
54
|
+
#
|
|
55
|
+
# Só o que vier muda; `nil` limpa. O e-mail (ou telefone) acha a pessoa que já chegou por
|
|
56
|
+
# outro caminho e ela é adotada, nunca duplicada; se ela estava em outro cliente, é
|
|
57
|
+
# transferida. `access: true` devolve o acesso retirado por {#delete}.
|
|
58
|
+
#
|
|
59
|
+
# @param access [Boolean] pode abrir chamados/usar o widget.
|
|
60
|
+
# @param is_primary [Boolean] contato principal do cliente.
|
|
61
|
+
# @param extra_emails [Array<String>] e-mails adicionais.
|
|
62
|
+
# @param extra_phones [Array<String>] telefones adicionais.
|
|
63
|
+
# @return [Hash] a pessoa + `"status"`.
|
|
64
|
+
def upsert(customer_external_id, person_external_id, name: UNSET, email: UNSET, phone: UNSET,
|
|
65
|
+
role: UNSET, access: UNSET, is_primary: UNSET, extra_emails: UNSET, extra_phones: UNSET,
|
|
66
|
+
idempotency_key: nil, timeout: nil)
|
|
67
|
+
cid = segment(customer_external_id, "customer_external_id")
|
|
68
|
+
pid = segment(person_external_id, "person_external_id")
|
|
69
|
+
person = compact(
|
|
70
|
+
"name" => name, "email" => email, "phone" => phone, "role" => role, "access" => access,
|
|
71
|
+
"is_primary" => is_primary, "extra_emails" => extra_emails, "extra_phones" => extra_phones
|
|
72
|
+
)
|
|
73
|
+
call("PUT", "/customers/#{cid}/people/#{pid}",
|
|
74
|
+
body: { "person" => person }, idempotency_key: idempotency_key, timeout: timeout)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Pessoas do cliente. `GET /customers/{customer_external_id}/people`
|
|
78
|
+
# @return [Array<Hash>]
|
|
79
|
+
def list(customer_external_id, timeout: nil)
|
|
80
|
+
call("GET", "/customers/#{segment(customer_external_id, 'customer_external_id')}/people",
|
|
81
|
+
timeout: timeout)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Retira o acesso da pessoa (ela continua no histórico).
|
|
85
|
+
# `DELETE /customers/{customer_external_id}/people/{person_external_id}`
|
|
86
|
+
# @return [Hash] a pessoa, com `"access" => false`.
|
|
87
|
+
def delete(customer_external_id, person_external_id, idempotency_key: nil, timeout: nil)
|
|
88
|
+
cid = segment(customer_external_id, "customer_external_id")
|
|
89
|
+
pid = segment(person_external_id, "person_external_id")
|
|
90
|
+
call("DELETE", "/customers/#{cid}/people/#{pid}",
|
|
91
|
+
idempotency_key: idempotency_key, timeout: timeout)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Cria/atualiza até {Bfocus::BATCH_MAX} (500) pessoas numa chamada. `POST /people/batch`
|
|
95
|
+
#
|
|
96
|
+
# Cada item (Hash plano, chaves string ou símbolo): `customer_external_id` e `external_id`
|
|
97
|
+
# (da pessoa), ambos obrigatórios, + os campos do {#upsert} (ausente = não muda; `nil`
|
|
98
|
+
# limpa). No fio vira `{"customer_external_id" => …, "person" => {"external_id" => …, …}}`.
|
|
99
|
+
#
|
|
100
|
+
# A SDK **não divide** o lote: mais de 500 itens lança `ArgumentError` antes de qualquer
|
|
101
|
+
# requisição (use `items.each_slice(Bfocus::BATCH_MAX)`); o `"index"` de cada resultado é
|
|
102
|
+
# a posição no lote enviado. Um item com erro não desfaz os outros. Lista vazia devolve o
|
|
103
|
+
# resultado zerado sem chamar a API.
|
|
104
|
+
#
|
|
105
|
+
# @return [Hash] `{"results" => [{"index", "status", "external_id", "merged_into", "error",
|
|
106
|
+
# "code"}, …], "summary" => {"created", "updated", "unchanged", "error"}}`
|
|
107
|
+
# @raise [ArgumentError] mais de 500 itens ou item sem `customer_external_id`/`external_id`.
|
|
108
|
+
# @raise [TypeError] `items` não é lista de Hash.
|
|
109
|
+
def batch(items, idempotency_key: nil, timeout: nil)
|
|
110
|
+
list = batch_items(items, "people.batch")
|
|
111
|
+
return empty_batch_result if list.empty?
|
|
112
|
+
|
|
113
|
+
wire = list.each_with_index.map do |item, index|
|
|
114
|
+
customer = batch_id!(item, "customer_external_id", "people.batch", index)
|
|
115
|
+
batch_id!(item, "external_id", "people.batch", index)
|
|
116
|
+
{ "customer_external_id" => customer, "person" => item.reject { |key, _| key == "customer_external_id" } }
|
|
117
|
+
end
|
|
118
|
+
call("POST", "/people/batch",
|
|
119
|
+
body: { "items" => wire }, idempotency_key: idempotency_key, timeout: timeout)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/bfocus/version.rb
CHANGED
data/lib/bfocus/widget.rb
CHANGED
|
@@ -27,4 +27,53 @@ module Bfocus
|
|
|
27
27
|
message = "v1:".b + utf8.call(user_external_id) + ":".b + utf8.call(customer_external_id)
|
|
28
28
|
OpenSSL::HMAC.hexdigest("SHA256", utf8.call(secret), message)
|
|
29
29
|
end
|
|
30
|
+
|
|
31
|
+
# Identidade do widget **v2 (com validade)**: como {sign_widget_identity}, mas carimbada com o
|
|
32
|
+
# instante. Roda no seu backend, sem rede e sem chave de API.
|
|
33
|
+
#
|
|
34
|
+
# Devolve `"v2.<ts>.<hex>"`: `ts` = segundos unix inteiros do instante (padrão: agora) e `hex`
|
|
35
|
+
# = HMAC-SHA256, em hexadecimal minúsculo, de
|
|
36
|
+
# `"v2:" + ts + ":" + user_external_id + ":" + customer_external_id` (UTF-8). A API aceita de
|
|
37
|
+
# 7 dias atrás até 5 minutos à frente — gere a cada renderização da página, nunca guarde. Vai
|
|
38
|
+
# no mesmo lugar da v1 (`userHash` do widget); a v1 continua aceita.
|
|
39
|
+
#
|
|
40
|
+
# @param secret [String] segredo de identidade do widget (painel do bFocus).
|
|
41
|
+
# @param user_external_id [String] `external_id` do usuário (a pessoa) no seu sistema — sem
|
|
42
|
+
# `:` (é o separador; a API recusa).
|
|
43
|
+
# @param customer_external_id [String] `external_id` do cliente (empresa). Prefira `-` a `:` nos
|
|
44
|
+
# seus ids (ex.: `"erp-1042"`).
|
|
45
|
+
# @param now [Integer, Time, nil] instante da assinatura: segundos unix (não ms) ou `Time`.
|
|
46
|
+
# Padrão: agora.
|
|
47
|
+
# @return [String] `"v2.<ts>.<64 hex>"`.
|
|
48
|
+
# @raise [ArgumentError] segredo vazio, id `nil`, `:` no id do usuário, instante negativo ou de
|
|
49
|
+
# tipo inválido.
|
|
50
|
+
#
|
|
51
|
+
# @example
|
|
52
|
+
# Bfocus.sign_widget_identity_v2(ENV.fetch("BFOCUS_WIDGET_SECRET"), "app-77", "erp-1042")
|
|
53
|
+
# # => "v2.<ts>.<hex>"
|
|
54
|
+
def self.sign_widget_identity_v2(secret, user_external_id, customer_external_id, now: nil)
|
|
55
|
+
raise ArgumentError, "sign_widget_identity_v2: secret é obrigatório." if secret.nil? || secret.to_s.empty?
|
|
56
|
+
raise ArgumentError, "sign_widget_identity_v2: user_external_id é obrigatório." if user_external_id.nil?
|
|
57
|
+
raise ArgumentError, "sign_widget_identity_v2: customer_external_id é obrigatório." if customer_external_id.nil?
|
|
58
|
+
if user_external_id.to_s.include?(":")
|
|
59
|
+
raise ArgumentError, "sign_widget_identity_v2: user_external_id não pode ter ':' (é o separador; " \
|
|
60
|
+
"a API recusa) — use outro, ex.: \"app-77\": #{user_external_id.to_s.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
ts = case now
|
|
64
|
+
when nil then Time.now.to_i
|
|
65
|
+
when Time then now.to_i
|
|
66
|
+
when Integer then now
|
|
67
|
+
else
|
|
68
|
+
raise ArgumentError, "sign_widget_identity_v2: now precisa ser Integer (segundos unix) ou Time."
|
|
69
|
+
end
|
|
70
|
+
raise ArgumentError, "sign_widget_identity_v2: now não pode ser negativo (#{ts})." if ts.negative?
|
|
71
|
+
|
|
72
|
+
utf8 = lambda do |value|
|
|
73
|
+
text = value.to_s
|
|
74
|
+
(text.encoding == ::Encoding::BINARY ? text : text.encode(::Encoding::UTF_8)).b
|
|
75
|
+
end
|
|
76
|
+
message = "v2:#{ts}:".b + utf8.call(user_external_id) + ":".b + utf8.call(customer_external_id)
|
|
77
|
+
"v2.#{ts}.#{OpenSSL::HMAC.hexdigest('SHA256', utf8.call(secret), message)}"
|
|
78
|
+
end
|
|
30
79
|
end
|
data/lib/bfocus.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# SDK oficial em Ruby da API pública do bFocus: clientes, produtos, release notes, base de
|
|
3
|
+
# SDK oficial em Ruby da API pública do bFocus: clientes, pessoas, produtos, release notes, base de
|
|
4
4
|
# conhecimento e agentes de IA. Só biblioteca padrão (net/http, json, openssl, securerandom).
|
|
5
5
|
#
|
|
6
6
|
# @example
|
|
@@ -26,6 +26,7 @@ require_relative "bfocus/codec"
|
|
|
26
26
|
require_relative "bfocus/transport"
|
|
27
27
|
require_relative "bfocus/resources/base"
|
|
28
28
|
require_relative "bfocus/resources/customers"
|
|
29
|
+
require_relative "bfocus/resources/people"
|
|
29
30
|
require_relative "bfocus/resources/products"
|
|
30
31
|
require_relative "bfocus/resources/release_notes"
|
|
31
32
|
require_relative "bfocus/resources/knowledge_base"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bfocus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Berni Software
|
|
@@ -28,6 +28,7 @@ files:
|
|
|
28
28
|
- lib/bfocus/resources/base.rb
|
|
29
29
|
- lib/bfocus/resources/customers.rb
|
|
30
30
|
- lib/bfocus/resources/knowledge_base.rb
|
|
31
|
+
- lib/bfocus/resources/people.rb
|
|
31
32
|
- lib/bfocus/resources/products.rb
|
|
32
33
|
- lib/bfocus/resources/release_notes.rb
|
|
33
34
|
- lib/bfocus/transport.rb
|