bfocus 0.1.0 → 0.2.1
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 +286 -5
- data/lib/bfocus/client.rb +4 -1
- data/lib/bfocus/errors.rb +8 -1
- data/lib/bfocus/resources/base.rb +42 -1
- data/lib/bfocus/resources/customers.rb +58 -1
- data/lib/bfocus/resources/people.rb +152 -0
- data/lib/bfocus/transport.rb +6 -0
- data/lib/bfocus/version.rb +1 -1
- data/lib/bfocus/widget.rb +49 -0
- data/lib/bfocus.rb +2 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3daefe09e7aa1fed098c71ffdbc8f0b43c020519b72031355aac21a8b16011ed
|
|
4
|
+
data.tar.gz: a21b02db9aff17837cf93cc6063b16324e4593382543fa7c1c0eedd032d916a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 111b5a2790952c3ba34c11d7d7f8d101b620b3e44b1869730d6663daf97a2f28c27880febb48aca95e3112f41e9159ba5c2174986d7242ad4b242f9ab8ff15c0
|
|
7
|
+
data.tar.gz: 8a8ee123b356b52e8c8fbfeded349d168bdad83c39a010e3328b7635a0424e13f5413295e2c991e4a9a82fe3444c3200cc0fbbe471c82132468a548ac02c0e02
|
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,257 @@ 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
|
+
### Ler os identificadores da pessoa (para reconciliar)
|
|
158
|
+
|
|
159
|
+
`client.people.list(...)` mostra só o identificador **principal** de cada pessoa. Quando dois
|
|
160
|
+
cadastros seus eram a mesma pessoa, um dos ids virou **extra** — e some da listagem sem ter sumido
|
|
161
|
+
do cadastro. É isso que faz a sua conferência fechar "633 de 636" sem explicar os 3.
|
|
162
|
+
|
|
163
|
+
`people.identifiers.list` é a fonte de verdade dessa conferência, e é **leitura**: antes dela era
|
|
164
|
+
preciso ESCREVER (tentar um `add`) para descobrir o que tinha acontecido. Aceita no caminho o id
|
|
165
|
+
principal **ou qualquer um dos extras**.
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
ids = client.people.identifiers.list("crm-p5") # o id extra que "sumiu" da listagem
|
|
169
|
+
ids["external_id"] # => "app-77" — o principal do cadastro
|
|
170
|
+
ids["identifiers"].each { |i| puts "#{i['external_id']} #{i['label']} #{i['source']}" }
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Pessoas
|
|
174
|
+
|
|
175
|
+
As pessoas (usuários do seu sistema) de cada cliente, em `client.people`. O `external_id` da pessoa
|
|
176
|
+
é o mesmo `user_external_id` que você assina para o [widget](#identidade-do-widget).
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
pessoa = client.people.upsert(
|
|
180
|
+
"erp-1042", "app-77",
|
|
181
|
+
name: "Paula Reis", email: "paula@padaria.example", role: "Financeiro", is_primary: true,
|
|
182
|
+
extra_emails: ["paula.reis@pessoal.example"]
|
|
183
|
+
)
|
|
184
|
+
pessoa["status"] # => "created", "updated" ou "unchanged"
|
|
185
|
+
|
|
186
|
+
client.people.list("erp-1042") # todas as pessoas do cliente
|
|
187
|
+
client.people.delete("erp-1042", "app-77") # retira o acesso; a pessoa continua no histórico
|
|
188
|
+
client.people.upsert("erp-1042", "app-77", access: true) # devolve o acesso
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- **Nunca duplica.** O e-mail (ou o telefone) acha a pessoa que já chegou por e-mail, pelo widget
|
|
192
|
+
ou por outro sistema, e ela é **adotada** (ganha o seu `external_id`).
|
|
193
|
+
- A mesma pessoa enviada com **outro cliente** é **transferida** para ele.
|
|
194
|
+
- Como no resto da SDK, só o que você passa muda; `nil` limpa (`phone: nil`).
|
|
195
|
+
- Campos: `name`, `email`, `phone`, `role`, `access` (pode usar o atendimento), `is_primary`
|
|
196
|
+
(contato principal), `extra_emails`, `extra_phones`, `custom_fields`, `clear`.
|
|
197
|
+
- Erros comuns (`code`): `CUSTOMER_NOT_FOUND`, `NAME_REQUIRED` (ao criar), `PERSON_EMAIL_TAKEN`,
|
|
198
|
+
`PERSON_PHONE_TAKEN`, `PERSON_CONTACT_OTHER_CUSTOMER`, `PERSON_EMAIL_STAFF`,
|
|
199
|
+
`PERSON_CLEAR_FIELD_INVALID`, `PERSON_CLEAR_NOT_OWN_RECORD`.
|
|
200
|
+
|
|
201
|
+
### Campos personalizados da pessoa
|
|
202
|
+
|
|
203
|
+
`custom_fields` leva o que só existe no seu sistema (matrícula, centro de custo, filial). É a
|
|
204
|
+
**exceção** ao "só o que vier muda": a lista enviada **substitui a lista inteira** — campo que
|
|
205
|
+
ficar de fora é **removido**. Mande sempre a lista que o seu sistema tem hoje; omitir o argumento não mexe
|
|
206
|
+
em nada, como em qualquer outro campo.
|
|
207
|
+
|
|
208
|
+
A `visibility` é decidida no bFocus e **preservada entre sincronizações** — por isso ela não vai
|
|
209
|
+
no envio, só volta na resposta: o seu ERP não rebaixa nem promove a exposição de um dado sem
|
|
210
|
+
querer.
|
|
211
|
+
|
|
212
|
+
Vale no upsert de pessoa, no lote de pessoas e na listagem de pessoas do cliente.
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
pessoa = client.people.upsert(
|
|
216
|
+
"erp-1042", "app-77",
|
|
217
|
+
custom_fields: [ # a lista INTEIRA do seu sistema
|
|
218
|
+
{ key: "matricula", label: "Matrícula", value: "4471" },
|
|
219
|
+
{ key: "filial", label: "Filial", value: "Centro" }
|
|
220
|
+
]
|
|
221
|
+
)
|
|
222
|
+
pessoa["custom_fields"].each do |campo|
|
|
223
|
+
puts "#{campo['key']} #{campo['value']} #{campo['visibility']}" # visibility vem do bFocus
|
|
224
|
+
end
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Apagar o e-mail ou o telefone da pessoa
|
|
228
|
+
|
|
229
|
+
Um contato gravado errado ficava preso para sempre: enquanto a ficha errada segurasse o telefone,
|
|
230
|
+
nenhum reenvio o soltava. `clear` apaga.
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
client.people.upsert("erp-1042", "app-77", clear: ["phone"]) # some o telefone
|
|
234
|
+
client.people.upsert("erp-1042", "app-77", clear: %w[email phone]) # somem os dois
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Três regras que parecem contraintuitivas e são de propósito:
|
|
238
|
+
|
|
239
|
+
- **Apagar é explícito.** `phone: nil`, `clear: []` e não passar o argumento continuam significando
|
|
240
|
+
**"não mexe"** — a SDK não traduz `nil` em `clear`. Fazer o `nil` apagar teria apagado, em
|
|
241
|
+
silêncio e na primeira carga seguinte, o dado de todo sistema que manda `nil` para "não tenho
|
|
242
|
+
esse valor".
|
|
243
|
+
- **Campo fora da lista é recusado, não ignorado**: hoje só `"email"` e `"phone"`; qualquer outro
|
|
244
|
+
devolve 422 `PERSON_CLEAR_FIELD_INVALID` (`Bfocus::ValidationError`).
|
|
245
|
+
- **Só se limpa a própria ficha.** Se você alcançou a pessoa por um identificador **extra**, a API
|
|
246
|
+
recusa com 409 `PERSON_CLEAR_NOT_OWN_RECORD` (`Bfocus::ConflictError`): apagar o contato de uma
|
|
247
|
+
ficha alcançada por apelido seria apagar dado de outro sistema. Para saber se o id que você tem em
|
|
248
|
+
mãos é o principal ou um extra, use `client.people.identifiers.list(...)`.
|
|
249
|
+
|
|
250
|
+
Vale no `people.upsert` e no `people.batch` (`"clear" => ["phone"]` no item).
|
|
251
|
+
|
|
252
|
+
### Contato já usado: um 409 que você consegue resolver
|
|
253
|
+
|
|
254
|
+
`PERSON_EMAIL_TAKEN` e `PERSON_PHONE_TAKEN` (409) não são "tente de novo": o e-mail (ou o
|
|
255
|
+
telefone) já é de outra pessoa da conta. O erro diz **de quem**, em `error.data` (a API repete o mesmo
|
|
256
|
+
detalhe em `error.validation`, por compatibilidade):
|
|
257
|
+
|
|
258
|
+
| campo | o que é |
|
|
259
|
+
| --- | --- |
|
|
260
|
+
| `field` | `email` ou `phone` — qual contato está tomado |
|
|
261
|
+
| `owner_external_id` | o identificador da pessoa que já usa esse contato |
|
|
262
|
+
| `owner_name` | o nome dela |
|
|
263
|
+
| `owner_customer_external_id` | o cliente a que ela pertence |
|
|
264
|
+
|
|
265
|
+
**É o `owner_customer_external_id` que decide a ação**, e os dois casos pedem coisas opostas:
|
|
266
|
+
|
|
267
|
+
- **mesmo cliente que você enviou** → é quase sempre a MESMA pessoa em dois sistemas. Uma pessoa
|
|
268
|
+
tem **N identificadores**: registre o seu como **extra** dela. A partir daí o seu id encontra
|
|
269
|
+
essa pessoa.
|
|
270
|
+
- **outro cliente** → ninguém decide sozinho a quem a pessoa pertence. Não force: registre o caso
|
|
271
|
+
e leve para quem conhece o cadastro. Unificar dois clientes é decisão de gente, não de um
|
|
272
|
+
casamento por e-mail.
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
begin
|
|
276
|
+
client.people.upsert("erp-1042", "app-77", name: "Paula Reis", email: "paula@padaria.example")
|
|
277
|
+
rescue Bfocus::ConflictError => e
|
|
278
|
+
raise unless %w[PERSON_EMAIL_TAKEN PERSON_PHONE_TAKEN].include?(e.code)
|
|
279
|
+
|
|
280
|
+
dono = e.data
|
|
281
|
+
if dono["owner_customer_external_id"] == "erp-1042"
|
|
282
|
+
# A mesma pessoa, com dois ids: o seu vira mais um identificador dela.
|
|
283
|
+
client.people.identifiers.add(dono["owner_external_id"], "app-77", label: "ERP")
|
|
284
|
+
else
|
|
285
|
+
# Dono em OUTRO cliente: não decida sozinho — registre e leve para o cadastro.
|
|
286
|
+
avisar_cadastro(e.code, dono)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`PERSON_CONTACT_OTHER_CUSTOMER` (409) é o mesmo assunto pelo outro lado, e é **recusa
|
|
292
|
+
definitiva**: a API não move mais uma pessoa de um cliente para outro só porque o e-mail (ou o
|
|
293
|
+
telefone) casou. Repetir a chamada não resolve — trate como caso para o cadastro, nunca como
|
|
294
|
+
falha temporária.
|
|
295
|
+
|
|
296
|
+
## Lotes — `customers.batch` e `people.batch`
|
|
297
|
+
|
|
298
|
+
Até **500 itens por chamada** (`Bfocus::BATCH_MAX`). Acima disso a SDK lança `ArgumentError` antes
|
|
299
|
+
de qualquer requisição — ela não divide sozinha, porque o `index` de cada resultado é a posição no
|
|
300
|
+
lote que **você** enviou. Divida assim:
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
clientes = meus_clientes.map do |c|
|
|
304
|
+
{ external_id: "erp-#{c.id}", name: c.nome, document: c.cnpj, email: c.email }
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
clientes.each_slice(Bfocus::BATCH_MAX) do |fatia|
|
|
308
|
+
resultado = client.customers.batch(fatia)
|
|
309
|
+
resultado["results"].each do |item|
|
|
310
|
+
next unless item["status"] == "error"
|
|
311
|
+
|
|
312
|
+
warn "#{fatia[item['index']][:external_id]}: #{item['error']} (HTTP #{item['code']})"
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
- Cada item de `customers.batch` é `external_id` + os campos do `customers.upsert`.
|
|
318
|
+
- Cada item de `people.batch` é **plano**: `customer_external_id` + `external_id` (da pessoa) + os
|
|
319
|
+
campos do `people.upsert`.
|
|
320
|
+
- Retorno: `results` (um por item: `index`, `status` — `created`/`updated`/`unchanged`/`error` —,
|
|
321
|
+
`external_id`, `merged_into`, `error` com o código estável e `code` com o status HTTP do item) +
|
|
322
|
+
`summary` (`created`, `updated`, `unchanged`, `error`).
|
|
323
|
+
- **Um erro não desfaz os outros**: confira `summary["error"]` e registre os itens com erro.
|
|
324
|
+
- Lista vazia devolve o resultado zerado sem fazer requisição.
|
|
325
|
+
- `idempotency_key:` vale para o lote inteiro (um lote = uma chamada).
|
|
326
|
+
|
|
327
|
+
## Sincronizar clientes e usuários do seu sistema
|
|
328
|
+
|
|
329
|
+
**Ids com o prefixo do sistema, sem `:`.** Use `-` como separador — `erp-1042` para clientes,
|
|
330
|
+
`app-77` para pessoas — ou UUIDs puros: vários sistemas seus convivem no mesmo bFocus sem colisão. A
|
|
331
|
+
assinatura do widget recusa `:` (é o separador dela), então não use `:` em nenhum `external_id` de
|
|
332
|
+
cliente ou pessoa.
|
|
333
|
+
|
|
334
|
+
**Carga inicial (no deploy):** clientes em fatias de 500 → vincule cada um ao produto → pessoas em
|
|
335
|
+
fatias de 500.
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
def carga_inicial(client, clientes, usuarios)
|
|
339
|
+
clientes.each_slice(Bfocus::BATCH_MAX) do |fatia|
|
|
340
|
+
r = client.customers.batch(fatia.map { |c| { external_id: "erp-#{c.id}", name: c.nome } })
|
|
341
|
+
registrar_erros(r, fatia) if r["summary"]["error"].positive?
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
clientes.each { |c| client.customers.products.attach("erp-#{c.id}", "erp-cloud") }
|
|
345
|
+
|
|
346
|
+
usuarios.each_slice(Bfocus::BATCH_MAX) do |fatia|
|
|
347
|
+
itens = fatia.map do |u|
|
|
348
|
+
{ customer_external_id: "erp-#{u.cliente_id}", external_id: "app-#{u.id}",
|
|
349
|
+
name: u.nome, email: u.email }
|
|
350
|
+
end
|
|
351
|
+
r = client.people.batch(itens)
|
|
352
|
+
registrar_erros(r, fatia) if r["summary"]["error"].positive?
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
**Depois, no dia a dia**, espelhe cada evento do seu sistema:
|
|
358
|
+
|
|
359
|
+
| No seu sistema | No bFocus |
|
|
360
|
+
| --- | --- |
|
|
361
|
+
| criou/alterou cliente | `customers.upsert` |
|
|
362
|
+
| criou/alterou usuário | `people.upsert` |
|
|
363
|
+
| excluiu/desativou usuário | `people.delete` |
|
|
364
|
+
| excluiu cliente | `customers.delete` |
|
|
365
|
+
|
|
366
|
+
Vincule o cliente ao produto com `customers.products.attach`. Se a resposta trouxer `merged_into`,
|
|
367
|
+
o cadastro foi unificado em outro: atualize o id do seu lado.
|
|
368
|
+
|
|
369
|
+
**Nunca bloqueie a requisição do seu usuário esperando o bFocus.** Enfileire (job/outbox) e tente de
|
|
370
|
+
novo com backoff; a SDK já repete 429/5xx com a mesma `Idempotency-Key`, e a fila cobre
|
|
371
|
+
indisponibilidades longas.
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
# app/jobs/bfocus_sync_usuario_job.rb (ActiveJob; o mesmo vale para Sidekiq etc.)
|
|
375
|
+
class BfocusSyncUsuarioJob < ApplicationJob
|
|
376
|
+
retry_on Bfocus::NetworkError, Bfocus::RateLimitError, Bfocus::ServerError,
|
|
377
|
+
wait: :polynomially_longer, attempts: 10
|
|
378
|
+
|
|
379
|
+
def perform(usuario_id)
|
|
380
|
+
u = Usuario.find(usuario_id)
|
|
381
|
+
if u.ativo?
|
|
382
|
+
BFOCUS.people.upsert("erp-#{u.cliente_id}", "app-#{u.id}", name: u.nome, email: u.email)
|
|
383
|
+
else
|
|
384
|
+
BFOCUS.people.delete("erp-#{u.cliente_id}", "app-#{u.id}")
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# no model, depois de salvar — a requisição do usuário não espera o bFocus:
|
|
390
|
+
after_commit { BfocusSyncUsuarioJob.perform_later(id) }
|
|
391
|
+
```
|
|
392
|
+
|
|
142
393
|
## Produtos
|
|
143
394
|
|
|
144
395
|
```ruby
|
|
@@ -273,6 +524,11 @@ Todos são `Hash` com chaves string (campos novos podem aparecer a qualquer mome
|
|
|
273
524
|
| Contato (`customers.contacts.*`) | `id`, `external_id`, `name`, `role`, `email`, `phone`, `notes`, `is_primary`, `created_at`, `updated_at` |
|
|
274
525
|
| Produto vinculado (`customers.products.*`) | `id`, `slug`, `name`, `is_active` |
|
|
275
526
|
| Interação (`customers.interactions.*`) | `id`, `content`, `is_internal`, `author_kind`, `author_name`, `created_at` |
|
|
527
|
+
| Pessoa (`people.list`, `delete`) | `external_id` (pode ser `nil`), `name`, `email`, `phone`, `role`, `access`, `is_primary`, `customer_external_id`, `custom_fields` (lista de `{key, label, value, visibility}`) |
|
|
528
|
+
| Pessoa gravada (`people.upsert`) | a pessoa + `status` (`created`/`updated`/`unchanged`) |
|
|
529
|
+
| Cliente com identificadores (`customers.identifiers.add`, `remove`) | o cliente + `identifiers` (lista de `{external_id, label, source}`) |
|
|
530
|
+
| Identificadores da pessoa (`people.identifiers.list`, `add`, `remove`) | `external_id`, `identifiers` (lista de `{external_id, label, source}`) |
|
|
531
|
+
| 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
532
|
| Produto (`products.*`) | `id`, `slug`, `name`, `description`, `color`, `icon`, `is_active`, `sort_order`, `current_version`, `ai_level`, `created_at`, `updated_at` |
|
|
277
533
|
| 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
534
|
| Artigo — resumo (`kb.articles.list`, `list_all`) | `id`, `external_id`, `product`, `title`, `excerpt`, `status`, `origin`, `published_at`, `created_at`, `updated_at` |
|
|
@@ -298,7 +554,10 @@ Qualquer resposta fora de 2xx levanta `Bfocus::Error` (ou uma subclasse):
|
|
|
298
554
|
| `Bfocus::ServerError` | 5xx |
|
|
299
555
|
| `Bfocus::NetworkError` | conexão/timeout — `status == 0`, `code == "NETWORK_ERROR"` |
|
|
300
556
|
|
|
301
|
-
Todas têm `code`, `status`, `request_id`, `validation`, `retry_after`, `required_scope` e
|
|
557
|
+
Todas têm `code`, `status`, `request_id`, `validation`, `data`, `retry_after`, `required_scope` e
|
|
558
|
+
`body`. O `data` é o `data` do corpo: o detalhe estruturado que alguns erros trazem (`{}` quando
|
|
559
|
+
não há) — é por ele que um 409 de contato tomado diz de **quem** é o contato (veja
|
|
560
|
+
[Pessoas](#pessoas)).
|
|
302
561
|
**Decida pelo `code`** — ele é estável (`CUSTOMER_NOT_FOUND`, `INTEGRATION_SCOPE_MISSING`,
|
|
303
562
|
`MODULE_NOT_CONTRACTED`, `VALIDATION_ERROR`…). O `message` é texto para humanos e pode mudar. Ao
|
|
304
563
|
falar com o suporte, informe o `request_id`: ele vem do corpo da resposta, senão do header
|
|
@@ -374,6 +633,28 @@ assinatura = Bfocus.sign_widget_identity(
|
|
|
374
633
|
# que abre o widget.
|
|
375
634
|
```
|
|
376
635
|
|
|
636
|
+
### Identidade do widget v2 (com validade)
|
|
637
|
+
|
|
638
|
+
A v2 carimba o instante na assinatura, então uma assinatura vazada deixa de valer sozinha:
|
|
639
|
+
|
|
640
|
+
```ruby
|
|
641
|
+
user_hash = Bfocus.sign_widget_identity_v2(
|
|
642
|
+
ENV.fetch("BFOCUS_WIDGET_SECRET"),
|
|
643
|
+
"app-77", # user_external_id: sem ":" (é o separador; a API recusa)
|
|
644
|
+
"erp-1042" # customer_external_id: a empresa dele
|
|
645
|
+
)
|
|
646
|
+
# => "v2.<ts>.<hex>", ex.: "v2.1789000000.9c1e…"
|
|
647
|
+
|
|
648
|
+
# instante explícito (segundos unix, não ms; ou Time) — útil em testes:
|
|
649
|
+
Bfocus.sign_widget_identity_v2(segredo, "app-77", "erp-1042", now: 1_789_000_000)
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
- `hex` = HMAC-SHA256 em hex minúsculo de `"v2:<ts>:<user_external_id>:<customer_external_id>"`.
|
|
653
|
+
- Vale de **7 dias atrás até 5 minutos à frente**: gere a cada renderização da página, **nunca
|
|
654
|
+
guarde**. Vai no mesmo lugar da v1 (`userHash` do widget).
|
|
655
|
+
- O id do usuário não pode ter `:` (`ArgumentError`) — use `-` como separador (`app-77`).
|
|
656
|
+
- A v1 continua aceita.
|
|
657
|
+
|
|
377
658
|
## Versões
|
|
378
659
|
|
|
379
660
|
**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)
|
data/lib/bfocus/errors.rb
CHANGED
|
@@ -21,6 +21,12 @@ module Bfocus
|
|
|
21
21
|
attr_reader :request_id
|
|
22
22
|
# @return [Hash{String=>String}] motivos por campo (erros de validação); `{}` quando não há.
|
|
23
23
|
attr_reader :validation
|
|
24
|
+
# @return [Hash] o `data` do corpo do erro: o detalhe estruturado que alguns erros trazem
|
|
25
|
+
# (`{}` quando não há). É onde vem, por exemplo, de quem é o contato já usado num 409
|
|
26
|
+
# `PERSON_EMAIL_TAKEN`/`PERSON_PHONE_TAKEN` (`field`, `owner_external_id`, `owner_name`,
|
|
27
|
+
# `owner_customer_external_id`) e o `owner` de um `IDENTIFIER_IN_USE`. A API repete esse
|
|
28
|
+
# detalhe em {#validation}, por compatibilidade com as SDKs que ainda não expunham `data`.
|
|
29
|
+
attr_reader :data
|
|
24
30
|
# @return [Integer, Float, nil] segundos do header `Retry-After` (só em 429).
|
|
25
31
|
attr_reader :retry_after
|
|
26
32
|
# @return [String, nil] escopo que faltou na chave (header `X-Required-Scope`, só em 403).
|
|
@@ -29,11 +35,12 @@ module Bfocus
|
|
|
29
35
|
attr_reader :body
|
|
30
36
|
|
|
31
37
|
def initialize(message = nil, code: nil, status: 0, request_id: nil, validation: nil,
|
|
32
|
-
retry_after: nil, required_scope: nil, body: nil)
|
|
38
|
+
retry_after: nil, required_scope: nil, body: nil, data: nil)
|
|
33
39
|
@code = code
|
|
34
40
|
@status = status
|
|
35
41
|
@request_id = request_id
|
|
36
42
|
@validation = validation.is_a?(Hash) ? validation.dup : {}
|
|
43
|
+
@data = data.is_a?(Hash) ? data.dup : {}
|
|
37
44
|
@retry_after = retry_after
|
|
38
45
|
@required_scope = required_scope
|
|
39
46
|
@body = body
|
|
@@ -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,152 @@
|
|
|
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
|
+
# Todos os identificadores da pessoa: o principal (`"external_id"` do retorno) e os extras.
|
|
12
|
+
# `GET /people/{person_external_id}/identifiers` (escopo `customers:read`). Aceita no
|
|
13
|
+
# caminho o principal OU qualquer um dos extras.
|
|
14
|
+
#
|
|
15
|
+
# É a fonte de verdade para RECONCILIAR: {People#list} mostra só o identificador principal,
|
|
16
|
+
# então um id que virou extra some de lá sem ter sumido do cadastro — e, sem esta leitura,
|
|
17
|
+
# era preciso ESCREVER (tentar um {#add}) para descobrir o que tinha acontecido.
|
|
18
|
+
#
|
|
19
|
+
# @return [Hash]
|
|
20
|
+
# @raise [Bfocus::NotFoundError] `PERSON_NOT_FOUND`.
|
|
21
|
+
def list(person_external_id, timeout: nil)
|
|
22
|
+
call("GET", "/people/#{segment(person_external_id, 'person_external_id')}/identifiers",
|
|
23
|
+
timeout: timeout)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Liga `extra_id` à pessoa (idempotente). `PUT /people/{person_external_id}/identifiers/{extra_id}`
|
|
27
|
+
#
|
|
28
|
+
# @param label [String, nil] rótulo livre. Não informado = sem corpo.
|
|
29
|
+
# @return [Hash]
|
|
30
|
+
def add(person_external_id, extra_id, label: UNSET, idempotency_key: nil, timeout: nil)
|
|
31
|
+
pid = segment(person_external_id, "person_external_id")
|
|
32
|
+
extra = segment(extra_id, "extra_id")
|
|
33
|
+
body = label.equal?(UNSET) ? nil : { "label" => label }
|
|
34
|
+
call("PUT", "/people/#{pid}/identifiers/#{extra}",
|
|
35
|
+
body: body, idempotency_key: idempotency_key, timeout: timeout)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Desliga `extra_id` da pessoa. `DELETE /people/{person_external_id}/identifiers/{extra_id}`
|
|
39
|
+
# @return [Hash]
|
|
40
|
+
def remove(person_external_id, extra_id, idempotency_key: nil, timeout: nil)
|
|
41
|
+
pid = segment(person_external_id, "person_external_id")
|
|
42
|
+
extra = segment(extra_id, "extra_id")
|
|
43
|
+
call("DELETE", "/people/#{pid}/identifiers/#{extra}",
|
|
44
|
+
idempotency_key: idempotency_key, timeout: timeout)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Pessoas (usuários do seu sistema) de um cliente — `client.people`. Sub-recurso:
|
|
49
|
+
# {#identifiers}.
|
|
50
|
+
#
|
|
51
|
+
# Pessoa: `"external_id"` (pode ser `nil` para quem chegou por e-mail/widget sem id),
|
|
52
|
+
# `"name"`, `"email"`, `"phone"`, `"role"`, `"access"`, `"is_primary"`,
|
|
53
|
+
# `"customer_external_id"` e `"custom_fields"` (lista de `{"key", "label", "value",
|
|
54
|
+
# "visibility"}`). O `upsert` devolve também `"status"`
|
|
55
|
+
# (`"created"`/`"updated"`/`"unchanged"`).
|
|
56
|
+
#
|
|
57
|
+
# O `external_id` da pessoa é o mesmo `user_external_id` assinado no widget — por isso não
|
|
58
|
+
# pode ter `:` (use outro separador, ex.: `"app-77"`).
|
|
59
|
+
class People < Base
|
|
60
|
+
# @return [PersonIdentifiers]
|
|
61
|
+
attr_reader :identifiers
|
|
62
|
+
|
|
63
|
+
def initialize(transport)
|
|
64
|
+
super
|
|
65
|
+
@identifiers = PersonIdentifiers.new(transport)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Cria ou atualiza uma pessoa do cliente pelo `external_id` dela.
|
|
69
|
+
# `PUT /customers/{customer_external_id}/people/{person_external_id}`
|
|
70
|
+
#
|
|
71
|
+
# Só o que vier muda; `nil` limpa. O e-mail (ou telefone) acha a pessoa que já chegou por
|
|
72
|
+
# outro caminho e ela é adotada, nunca duplicada; se ela estava em outro cliente, é
|
|
73
|
+
# transferida. `access: true` devolve o acesso retirado por {#delete}.
|
|
74
|
+
#
|
|
75
|
+
# @param access [Boolean] pode abrir chamados/usar o widget.
|
|
76
|
+
# @param is_primary [Boolean] contato principal do cliente.
|
|
77
|
+
# @param extra_emails [Array<String>] e-mails adicionais.
|
|
78
|
+
# @param extra_phones [Array<String>] telefones adicionais.
|
|
79
|
+
# @param custom_fields [Array<Hash>] campos personalizados (`{"key", "label", "value"}`).
|
|
80
|
+
# Ao contrário de `extra_emails`/`extra_phones`, a lista SUBSTITUI a lista inteira: mande
|
|
81
|
+
# o que o seu sistema tem hoje, porque campo que ficar de fora é REMOVIDO. Não passar o
|
|
82
|
+
# argumento não mexe em nada. A visibilidade é decidida no bFocus e preservada entre
|
|
83
|
+
# sincronizações.
|
|
84
|
+
# @param clear [Array<String>] campos a APAGAR nesta pessoa: `["email"]`, `["phone"]` ou os
|
|
85
|
+
# dois. Apagar é EXPLÍCITO: `phone: nil`, `clear: []` e não passar o argumento continuam
|
|
86
|
+
# significando "não mexe" — a SDK não traduz `nil` em `clear`. Campo fora da lista aceita
|
|
87
|
+
# é RECUSADO pela API (422 `PERSON_CLEAR_FIELD_INVALID`), não ignorado; e só se limpa a
|
|
88
|
+
# PRÓPRIA ficha: alcançando a pessoa por um identificador EXTRA, a API recusa (409
|
|
89
|
+
# `PERSON_CLEAR_NOT_OWN_RECORD`) — apagar contato de ficha alcançada por apelido seria
|
|
90
|
+
# apagar dado de outro sistema.
|
|
91
|
+
# @return [Hash] a pessoa + `"status"`.
|
|
92
|
+
def upsert(customer_external_id, person_external_id, name: UNSET, email: UNSET, phone: UNSET,
|
|
93
|
+
role: UNSET, access: UNSET, is_primary: UNSET, extra_emails: UNSET, extra_phones: UNSET,
|
|
94
|
+
custom_fields: UNSET, clear: UNSET, idempotency_key: nil, timeout: nil)
|
|
95
|
+
cid = segment(customer_external_id, "customer_external_id")
|
|
96
|
+
pid = segment(person_external_id, "person_external_id")
|
|
97
|
+
person = compact(
|
|
98
|
+
"name" => name, "email" => email, "phone" => phone, "role" => role, "access" => access,
|
|
99
|
+
"is_primary" => is_primary, "extra_emails" => extra_emails, "extra_phones" => extra_phones,
|
|
100
|
+
"custom_fields" => custom_fields, "clear" => clear
|
|
101
|
+
)
|
|
102
|
+
call("PUT", "/customers/#{cid}/people/#{pid}",
|
|
103
|
+
body: { "person" => person }, idempotency_key: idempotency_key, timeout: timeout)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Pessoas do cliente. `GET /customers/{customer_external_id}/people`
|
|
107
|
+
# @return [Array<Hash>]
|
|
108
|
+
def list(customer_external_id, timeout: nil)
|
|
109
|
+
call("GET", "/customers/#{segment(customer_external_id, 'customer_external_id')}/people",
|
|
110
|
+
timeout: timeout)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Retira o acesso da pessoa (ela continua no histórico).
|
|
114
|
+
# `DELETE /customers/{customer_external_id}/people/{person_external_id}`
|
|
115
|
+
# @return [Hash] a pessoa, com `"access" => false`.
|
|
116
|
+
def delete(customer_external_id, person_external_id, idempotency_key: nil, timeout: nil)
|
|
117
|
+
cid = segment(customer_external_id, "customer_external_id")
|
|
118
|
+
pid = segment(person_external_id, "person_external_id")
|
|
119
|
+
call("DELETE", "/customers/#{cid}/people/#{pid}",
|
|
120
|
+
idempotency_key: idempotency_key, timeout: timeout)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Cria/atualiza até {Bfocus::BATCH_MAX} (500) pessoas numa chamada. `POST /people/batch`
|
|
124
|
+
#
|
|
125
|
+
# Cada item (Hash plano, chaves string ou símbolo): `customer_external_id` e `external_id`
|
|
126
|
+
# (da pessoa), ambos obrigatórios, + os campos do {#upsert} (ausente = não muda; `nil`
|
|
127
|
+
# limpa). No fio vira `{"customer_external_id" => …, "person" => {"external_id" => …, …}}`.
|
|
128
|
+
#
|
|
129
|
+
# A SDK **não divide** o lote: mais de 500 itens lança `ArgumentError` antes de qualquer
|
|
130
|
+
# requisição (use `items.each_slice(Bfocus::BATCH_MAX)`); o `"index"` de cada resultado é
|
|
131
|
+
# a posição no lote enviado. Um item com erro não desfaz os outros. Lista vazia devolve o
|
|
132
|
+
# resultado zerado sem chamar a API.
|
|
133
|
+
#
|
|
134
|
+
# @return [Hash] `{"results" => [{"index", "status", "external_id", "merged_into", "error",
|
|
135
|
+
# "code"}, …], "summary" => {"created", "updated", "unchanged", "error"}}`
|
|
136
|
+
# @raise [ArgumentError] mais de 500 itens ou item sem `customer_external_id`/`external_id`.
|
|
137
|
+
# @raise [TypeError] `items` não é lista de Hash.
|
|
138
|
+
def batch(items, idempotency_key: nil, timeout: nil)
|
|
139
|
+
list = batch_items(items, "people.batch")
|
|
140
|
+
return empty_batch_result if list.empty?
|
|
141
|
+
|
|
142
|
+
wire = list.each_with_index.map do |item, index|
|
|
143
|
+
customer = batch_id!(item, "customer_external_id", "people.batch", index)
|
|
144
|
+
batch_id!(item, "external_id", "people.batch", index)
|
|
145
|
+
{ "customer_external_id" => customer, "person" => item.reject { |key, _| key == "customer_external_id" } }
|
|
146
|
+
end
|
|
147
|
+
call("POST", "/people/batch",
|
|
148
|
+
body: { "items" => wire }, idempotency_key: idempotency_key, timeout: timeout)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
data/lib/bfocus/transport.rb
CHANGED
|
@@ -211,6 +211,7 @@ module Bfocus
|
|
|
211
211
|
human = nil
|
|
212
212
|
request_id = nil
|
|
213
213
|
validation = {}
|
|
214
|
+
data = {}
|
|
214
215
|
|
|
215
216
|
if payload.is_a?(Hash)
|
|
216
217
|
err = payload["error"]
|
|
@@ -222,6 +223,10 @@ module Bfocus
|
|
|
222
223
|
end
|
|
223
224
|
human = msg if msg.is_a?(String) && !msg.empty? && msg != code
|
|
224
225
|
validation = payload["validation"] if payload["validation"].is_a?(Hash)
|
|
226
|
+
# `data` é o detalhe estruturado do erro (de quem é o contato já usado, o dono de um
|
|
227
|
+
# identificador…). A API também o repete em `validation`, mas quem lê o erro precisa
|
|
228
|
+
# alcançá-lo sem depender dessa duplicação.
|
|
229
|
+
data = payload["data"] if payload["data"].is_a?(Hash)
|
|
225
230
|
rid = payload["request_id"]
|
|
226
231
|
request_id = rid if rid.is_a?(String) && !rid.empty?
|
|
227
232
|
elsif !text.strip.empty?
|
|
@@ -247,6 +252,7 @@ module Bfocus
|
|
|
247
252
|
status: status,
|
|
248
253
|
request_id: request_id,
|
|
249
254
|
validation: validation,
|
|
255
|
+
data: data,
|
|
250
256
|
retry_after: retry_after,
|
|
251
257
|
required_scope: required_scope,
|
|
252
258
|
body: payload.nil? ? present(text) : payload
|
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,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bfocus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Berni Software
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Clientes, produtos, release notes, base de conhecimento e agentes de
|
|
14
14
|
IA do bFocus. Zero dependências de runtime, novas tentativas e idempotência automáticas.
|
|
@@ -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
|