bfocus 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {bfocus-0.1.0 → bfocus-0.2.0}/PKG-INFO +213 -6
- {bfocus-0.1.0 → bfocus-0.2.0}/README.md +212 -5
- {bfocus-0.1.0 → bfocus-0.2.0}/bfocus/__init__.py +4 -1
- {bfocus-0.1.0 → bfocus-0.2.0}/bfocus/_client.py +8 -3
- {bfocus-0.1.0 → bfocus-0.2.0}/bfocus/_resources.py +328 -2
- {bfocus-0.1.0 → bfocus-0.2.0}/bfocus/_version.py +1 -1
- {bfocus-0.1.0 → bfocus-0.2.0}/bfocus/types.py +122 -0
- bfocus-0.2.0/bfocus/widget.py +101 -0
- {bfocus-0.1.0 → bfocus-0.2.0}/pyproject.toml +1 -1
- {bfocus-0.1.0 → bfocus-0.2.0}/tests/fixtures/cases.json +906 -0
- {bfocus-0.1.0 → bfocus-0.2.0}/tests/test_conformance.py +23 -1
- {bfocus-0.1.0 → bfocus-0.2.0}/tests/test_unit.py +131 -0
- bfocus-0.1.0/bfocus/widget.py +0 -36
- {bfocus-0.1.0 → bfocus-0.2.0}/.gitignore +0 -0
- {bfocus-0.1.0 → bfocus-0.2.0}/LICENSE +0 -0
- {bfocus-0.1.0 → bfocus-0.2.0}/bfocus/_transport.py +0 -0
- {bfocus-0.1.0 → bfocus-0.2.0}/bfocus/errors.py +0 -0
- {bfocus-0.1.0 → bfocus-0.2.0}/bfocus/py.typed +0 -0
- {bfocus-0.1.0 → bfocus-0.2.0}/examples/quickstart.py +0 -0
- {bfocus-0.1.0 → bfocus-0.2.0}/tests/_support.py +0 -0
- {bfocus-0.1.0 → bfocus-0.2.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bfocus
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: SDK oficial em Python da API pública do bFocus: clientes, produtos, release notes, base de conhecimento e agentes de IA.
|
|
5
5
|
Project-URL: Homepage, https://bfocus.com.br
|
|
6
6
|
Project-URL: Repository, https://github.com/bernisoftware/bfocus-python
|
|
@@ -27,8 +27,8 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
|
|
28
28
|
# bfocus
|
|
29
29
|
|
|
30
|
-
SDK oficial em **Python** da API pública do [bFocus](https://bfocus.com.br): clientes,
|
|
31
|
-
release notes, base de conhecimento e agentes de IA.
|
|
30
|
+
SDK oficial em **Python** da API pública do [bFocus](https://bfocus.com.br): clientes, pessoas dos
|
|
31
|
+
clientes, produtos, release notes, base de conhecimento e agentes de IA.
|
|
32
32
|
|
|
33
33
|
Zero dependências (só biblioteca padrão) · Python 3.9+ · tipada (`py.typed`) · novas tentativas e
|
|
34
34
|
idempotência automáticas.
|
|
@@ -59,8 +59,8 @@ integração precisa. Ela vai em `Authorization: Bearer <chave>` em toda requisi
|
|
|
59
59
|
|
|
60
60
|
| Escopo | Permite |
|
|
61
61
|
| --- | --- |
|
|
62
|
-
| `customers:read` | Ler clientes, contatos, produtos vinculados e interações |
|
|
63
|
-
| `customers:write` | Cadastrar, atualizar e excluir clientes, contatos e interações |
|
|
62
|
+
| `customers:read` | Ler clientes, contatos, pessoas, produtos vinculados e interações |
|
|
63
|
+
| `customers:write` | Cadastrar, atualizar e excluir clientes, contatos, pessoas, identificadores extras e interações (inclui os lotes) |
|
|
64
64
|
| `products:read` | Ler o catálogo de produtos |
|
|
65
65
|
| `products:write` | Cadastrar, atualizar e arquivar produtos |
|
|
66
66
|
| `kb:read` | Ler e buscar artigos da base de conhecimento |
|
|
@@ -108,6 +108,24 @@ Construir o cliente não faz nenhuma chamada de rede.
|
|
|
108
108
|
- Datas (`updated_since`) aceitam `datetime` — convertido para ISO 8601 em UTC com `Z`; sem fuso é
|
|
109
109
|
tratado como UTC — ou string, que passa como veio.
|
|
110
110
|
|
|
111
|
+
## Recursos e métodos
|
|
112
|
+
|
|
113
|
+
| Recurso | Métodos |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `bf.customers` | `upsert`, `get`, `list`, `list_all`, `delete`, `batch` |
|
|
116
|
+
| `bf.customers.contacts` | `list`, `upsert`, `delete` |
|
|
117
|
+
| `bf.customers.products` | `list`, `attach`, `detach` |
|
|
118
|
+
| `bf.customers.interactions` | `list`, `list_all`, `create` |
|
|
119
|
+
| `bf.customers.identifiers` | `add`, `remove` |
|
|
120
|
+
| `bf.people` | `upsert`, `list`, `delete`, `batch` |
|
|
121
|
+
| `bf.people.identifiers` | `add`, `remove` |
|
|
122
|
+
| `bf.products` | `list`, `get`, `upsert`, `archive` |
|
|
123
|
+
| `bf.release_notes` | `list`, `list_all`, `get`, `upsert`, `publish` |
|
|
124
|
+
| `bf.kb` | `search` |
|
|
125
|
+
| `bf.kb.articles` | `list`, `list_all`, `get`, `upsert`, `batch_upsert`, `publish`, `unpublish`, `delete` |
|
|
126
|
+
| `bf.ai_agents` | `list`, `get`, `preview` |
|
|
127
|
+
| `bfocus` (funções) | `sign_widget_identity`, `sign_widget_identity_v2` (locais, sem rede) |
|
|
128
|
+
|
|
111
129
|
## Clientes
|
|
112
130
|
|
|
113
131
|
```python
|
|
@@ -151,6 +169,174 @@ for i in bf.customers.interactions.list_all("ERP 1042"):
|
|
|
151
169
|
print(i["created_at"], i["content"])
|
|
152
170
|
```
|
|
153
171
|
|
|
172
|
+
## Pessoas
|
|
173
|
+
|
|
174
|
+
Pessoas são quem usa o sistema do seu cliente e abre chamados/conversas no widget. O
|
|
175
|
+
`external_id` da pessoa é o mesmo `user_external_id` que você assina para o widget — por isso
|
|
176
|
+
**não pode ter `:`**.
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
p = bf.people.upsert(
|
|
180
|
+
"erp-1042", # o cliente
|
|
181
|
+
"app-77", # a pessoa (o usuário no seu sistema)
|
|
182
|
+
name="Paula Reis",
|
|
183
|
+
email="paula@padaria.example",
|
|
184
|
+
role="Financeiro",
|
|
185
|
+
is_primary=True,
|
|
186
|
+
extra_emails=["paula.reis@pessoal.example"], # somam aos que já existem
|
|
187
|
+
)
|
|
188
|
+
print(p["status"]) # "created", "updated" ou "unchanged"
|
|
189
|
+
|
|
190
|
+
for pessoa in bf.people.list("erp-1042"):
|
|
191
|
+
print(pessoa["name"], pessoa["access"])
|
|
192
|
+
|
|
193
|
+
bf.people.delete("erp-1042", "app-77") # retira o acesso
|
|
194
|
+
bf.people.upsert("erp-1042", "app-77", access=True) # devolve o acesso
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- **Nunca duplica**: se o e-mail (ou o telefone) já pertence a uma pessoa que chegou por e-mail
|
|
198
|
+
ou por outro sistema, ela é **adotada** e ganha o seu `external_id`.
|
|
199
|
+
- A mesma pessoa informada com **outro cliente** é transferida para ele.
|
|
200
|
+
- `delete` **retira o acesso** (devolve a pessoa com `access=False`); ela continua no histórico
|
|
201
|
+
de chamados e conversas. Um `upsert` com `access=True` devolve o acesso.
|
|
202
|
+
- Como nos outros upserts, só o que você passa muda; `name` é obrigatório ao criar.
|
|
203
|
+
|
|
204
|
+
## Lotes
|
|
205
|
+
|
|
206
|
+
`customers.batch` e `people.batch` criam/atualizam **até 500 itens por chamada** (`bfocus.BATCH_MAX`).
|
|
207
|
+
Acima disso a SDK levanta `ValueError` antes de chamar a API — ela **não** divide sozinha, porque
|
|
208
|
+
o `index` de cada resultado é a posição no lote que você enviou. Divida em fatias:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from bfocus import BATCH_MAX
|
|
212
|
+
|
|
213
|
+
clientes = [
|
|
214
|
+
{"external_id": "erp-1042", "name": "Padaria Estrela", "document": "12.345.678/0001-90"},
|
|
215
|
+
{"external_id": "erp-1043", "name": "Mercado Sol", "email": "contato@mercadosol.example"},
|
|
216
|
+
# ... quantos forem
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
for inicio in range(0, len(clientes), BATCH_MAX):
|
|
220
|
+
fatia = clientes[inicio:inicio + BATCH_MAX]
|
|
221
|
+
res = bf.customers.batch(fatia)
|
|
222
|
+
print(res["summary"]) # {"created": 1, "updated": 1, "unchanged": 0, "error": 0}
|
|
223
|
+
for r in res["results"]:
|
|
224
|
+
if r["status"] == "error":
|
|
225
|
+
item = fatia[r["index"]] # index = posição NESTA fatia
|
|
226
|
+
print("falhou:", item["external_id"], r["error"], r["code"]) # ex.: NAME_REQUIRED 422
|
|
227
|
+
elif r["merged_into"]:
|
|
228
|
+
print(fatia[r["index"]]["external_id"], "é extra; o principal é", r["merged_into"])
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
- Item de `customers.batch`: os campos do `customers.upsert` + `external_id` (obrigatório).
|
|
232
|
+
Chave ausente não muda; `None` limpa.
|
|
233
|
+
- Item de `people.batch` (plano): `customer_external_id` + `external_id` da pessoa + os campos
|
|
234
|
+
do `people.upsert`:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
bf.people.batch([
|
|
238
|
+
{"customer_external_id": "erp-1042", "external_id": "app-77",
|
|
239
|
+
"name": "Paula Reis", "email": "paula@padaria.example", "is_primary": True},
|
|
240
|
+
{"customer_external_id": "erp-1043", "external_id": "app-78", "name": "Rui Lima"},
|
|
241
|
+
])
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
- Resultado por item: `index`, `status` (`created`, `updated`, `unchanged` ou `error`),
|
|
245
|
+
`external_id`, `merged_into` (o id enviado é extra: este é o principal), `error` (código
|
|
246
|
+
estável) e `code` (status HTTP que o item teria sozinho); mais `summary` com os contadores.
|
|
247
|
+
- **Um item com erro não desfaz os outros.** Lista vazia devolve o resultado zerado sem fazer
|
|
248
|
+
requisição.
|
|
249
|
+
|
|
250
|
+
## Identificadores extras
|
|
251
|
+
|
|
252
|
+
Ligue o id de **outro** sistema seu (CRM, e-commerce…) ao mesmo cadastro, sem duplicar. É
|
|
253
|
+
idempotente; se o id já pertence a outro cadastro, a API responde 409 `IDENTIFIER_IN_USE`
|
|
254
|
+
(`ConflictError`).
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
c = bf.customers.identifiers.add("erp-1042", "crm-88", label="CRM")
|
|
258
|
+
print(c["identifiers"]) # [{"external_id": "crm-88", "label": "CRM", "source": "api"}]
|
|
259
|
+
bf.customers.identifiers.remove("erp-1042", "crm-88")
|
|
260
|
+
|
|
261
|
+
bf.people.identifiers.add("app-77", "crm-p5") # sem label
|
|
262
|
+
bf.people.identifiers.remove("app-77", "crm-p5")
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Num lote, um item enviado com um id extra volta com o principal em `merged_into`.
|
|
266
|
+
|
|
267
|
+
## Sincronizar clientes e usuários do seu sistema
|
|
268
|
+
|
|
269
|
+
**Ids com o prefixo do sistema, sem `:`** — a assinatura do widget recusa `:`. Use `-` como
|
|
270
|
+
separador (`erp-1042` para clientes, `app-77` para pessoas) ou UUIDs puros. Assim vários
|
|
271
|
+
sistemas seus convivem no mesmo bFocus sem colisão.
|
|
272
|
+
|
|
273
|
+
**1. Carga inicial (no deploy da integração)**: clientes em fatias de 500 → vínculo com o produto →
|
|
274
|
+
pessoas em fatias de 500. Confira `summary["error"]` e registre os itens com erro.
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
import logging
|
|
278
|
+
import os
|
|
279
|
+
|
|
280
|
+
from bfocus import BATCH_MAX, Bfocus
|
|
281
|
+
|
|
282
|
+
log = logging.getLogger("bfocus-sync")
|
|
283
|
+
bf = Bfocus(os.environ["BFOCUS_API_KEY"]) # escopo customers:write
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def em_fatias(itens, rodada):
|
|
287
|
+
for inicio in range(0, len(itens), BATCH_MAX):
|
|
288
|
+
fatia = itens[inicio:inicio + BATCH_MAX]
|
|
289
|
+
res = rodada(fatia)
|
|
290
|
+
if res["summary"]["error"]:
|
|
291
|
+
for r in res["results"]:
|
|
292
|
+
if r["status"] == "error":
|
|
293
|
+
log.warning("bfocus: %s -> %s", fatia[r["index"]]["external_id"], r["error"])
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
clientes = [{"external_id": f"erp-{c.id}", "name": c.nome, "document": c.cnpj}
|
|
297
|
+
for c in Cliente.objects.all()]
|
|
298
|
+
em_fatias(clientes, bf.customers.batch)
|
|
299
|
+
|
|
300
|
+
for c in clientes:
|
|
301
|
+
bf.customers.products.attach(c["external_id"], "erp-cloud") # idempotente
|
|
302
|
+
|
|
303
|
+
pessoas = [{"customer_external_id": f"erp-{u.cliente_id}", "external_id": f"app-{u.id}",
|
|
304
|
+
"name": u.nome, "email": u.email}
|
|
305
|
+
for u in Usuario.objects.all()]
|
|
306
|
+
em_fatias(pessoas, bf.people.batch)
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
**2. No dia a dia**: cada mudança no seu sistema vira uma chamada.
|
|
310
|
+
|
|
311
|
+
| No seu sistema | No bFocus |
|
|
312
|
+
| --- | --- |
|
|
313
|
+
| criou/alterou cliente | `bf.customers.upsert(...)` (+ `bf.customers.products.attach(...)` para ligar ao produto) |
|
|
314
|
+
| criou/alterou usuário | `bf.people.upsert(...)` |
|
|
315
|
+
| excluiu/desativou usuário | `bf.people.delete(...)` |
|
|
316
|
+
| excluiu cliente | `bf.customers.delete(...)` |
|
|
317
|
+
|
|
318
|
+
Se a resposta trouxer `merged_into`, atualize o id do seu lado.
|
|
319
|
+
|
|
320
|
+
**Nunca bloqueie a requisição do seu usuário esperando o bFocus**: enfileire (job/outbox) e
|
|
321
|
+
tente de novo com backoff. A SDK já repete 429/5xx com a mesma `Idempotency-Key`; a fila cobre
|
|
322
|
+
indisponibilidades longas.
|
|
323
|
+
|
|
324
|
+
```python
|
|
325
|
+
# no seu código de aplicação: só enfileira
|
|
326
|
+
def usuario_salvo(usuario):
|
|
327
|
+
fila.enqueue(sincronizar_usuario, usuario.id)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# no worker (Celery, RQ, cron…): chama o bFocus; se falhar, a fila tenta de novo com backoff
|
|
331
|
+
def sincronizar_usuario(usuario_id):
|
|
332
|
+
u = Usuario.objects.get(id=usuario_id)
|
|
333
|
+
if not u.ativo:
|
|
334
|
+
bf.people.delete(f"erp-{u.cliente_id}", f"app-{u.id}")
|
|
335
|
+
return
|
|
336
|
+
bf.people.upsert(f"erp-{u.cliente_id}", f"app-{u.id}", name=u.nome, email=u.email,
|
|
337
|
+
access=True)
|
|
338
|
+
```
|
|
339
|
+
|
|
154
340
|
## Produtos
|
|
155
341
|
|
|
156
342
|
```python
|
|
@@ -317,7 +503,8 @@ except BfocusError as err:
|
|
|
317
503
|
```
|
|
318
504
|
|
|
319
505
|
Argumento inválido no seu código (chave vazia; parâmetro de caminho vazio, `"."` ou `".."`; `/`
|
|
320
|
-
no `external_id` de um artigo
|
|
506
|
+
no `external_id` de um artigo; mais de 500 itens num `customers.batch`/`people.batch`; `:` no
|
|
507
|
+
usuário da assinatura v2 do widget) levanta `ValueError`/`TypeError` na hora, sem chamar a API.
|
|
321
508
|
|
|
322
509
|
## Novas tentativas e idempotência
|
|
323
510
|
|
|
@@ -362,6 +549,26 @@ assinatura = sign_widget_identity(
|
|
|
362
549
|
# que abre o widget.
|
|
363
550
|
```
|
|
364
551
|
|
|
552
|
+
### Identidade v2 (com validade)
|
|
553
|
+
|
|
554
|
+
A v2 carrega o instante da assinatura e expira: a API aceita de **7 dias atrás até 5 minutos à
|
|
555
|
+
frente**. Gere a cada renderização da página e nunca guarde. Vai no mesmo lugar da v1 (o
|
|
556
|
+
`userHash` do widget); a v1 continua aceita.
|
|
557
|
+
|
|
558
|
+
```python
|
|
559
|
+
from bfocus import sign_widget_identity_v2
|
|
560
|
+
|
|
561
|
+
assinatura = sign_widget_identity_v2(
|
|
562
|
+
os.environ["BFOCUS_WIDGET_SECRET"],
|
|
563
|
+
user_external_id="app-77", # SEM ":" (é o separador; a SDK levanta ValueError)
|
|
564
|
+
customer_external_id="erp-1042",
|
|
565
|
+
)
|
|
566
|
+
# "v2.<ts>.<hex>": ts = segundos unix de agora; hex = HMAC-SHA256 de "v2:<ts>:app-77:erp-1042"
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
Para testes, fixe o instante com `now=` (segundos unix `int`/`float` — não milissegundos — ou
|
|
570
|
+
`datetime`; sem fuso é tratado como UTC).
|
|
571
|
+
|
|
365
572
|
## Versões
|
|
366
573
|
|
|
367
574
|
**Fixe a versão exata** (`bfocus==0.1.0` no `requirements.txt` / `pyproject.toml`) e suba de uma
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# bfocus
|
|
2
2
|
|
|
3
|
-
SDK oficial em **Python** 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 **Python** da API pública do [bFocus](https://bfocus.com.br): clientes, pessoas dos
|
|
4
|
+
clientes, produtos, release notes, base de conhecimento e agentes de IA.
|
|
5
5
|
|
|
6
6
|
Zero dependências (só biblioteca padrão) · Python 3.9+ · tipada (`py.typed`) · novas tentativas e
|
|
7
7
|
idempotência automáticas.
|
|
@@ -32,8 +32,8 @@ integração precisa. Ela vai em `Authorization: Bearer <chave>` em toda requisi
|
|
|
32
32
|
|
|
33
33
|
| Escopo | Permite |
|
|
34
34
|
| --- | --- |
|
|
35
|
-
| `customers:read` | Ler clientes, contatos, produtos vinculados e interações |
|
|
36
|
-
| `customers:write` | Cadastrar, atualizar e excluir clientes, contatos e interações |
|
|
35
|
+
| `customers:read` | Ler clientes, contatos, pessoas, produtos vinculados e interações |
|
|
36
|
+
| `customers:write` | Cadastrar, atualizar e excluir clientes, contatos, pessoas, identificadores extras e interações (inclui os lotes) |
|
|
37
37
|
| `products:read` | Ler o catálogo de produtos |
|
|
38
38
|
| `products:write` | Cadastrar, atualizar e arquivar produtos |
|
|
39
39
|
| `kb:read` | Ler e buscar artigos da base de conhecimento |
|
|
@@ -81,6 +81,24 @@ Construir o cliente não faz nenhuma chamada de rede.
|
|
|
81
81
|
- Datas (`updated_since`) aceitam `datetime` — convertido para ISO 8601 em UTC com `Z`; sem fuso é
|
|
82
82
|
tratado como UTC — ou string, que passa como veio.
|
|
83
83
|
|
|
84
|
+
## Recursos e métodos
|
|
85
|
+
|
|
86
|
+
| Recurso | Métodos |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `bf.customers` | `upsert`, `get`, `list`, `list_all`, `delete`, `batch` |
|
|
89
|
+
| `bf.customers.contacts` | `list`, `upsert`, `delete` |
|
|
90
|
+
| `bf.customers.products` | `list`, `attach`, `detach` |
|
|
91
|
+
| `bf.customers.interactions` | `list`, `list_all`, `create` |
|
|
92
|
+
| `bf.customers.identifiers` | `add`, `remove` |
|
|
93
|
+
| `bf.people` | `upsert`, `list`, `delete`, `batch` |
|
|
94
|
+
| `bf.people.identifiers` | `add`, `remove` |
|
|
95
|
+
| `bf.products` | `list`, `get`, `upsert`, `archive` |
|
|
96
|
+
| `bf.release_notes` | `list`, `list_all`, `get`, `upsert`, `publish` |
|
|
97
|
+
| `bf.kb` | `search` |
|
|
98
|
+
| `bf.kb.articles` | `list`, `list_all`, `get`, `upsert`, `batch_upsert`, `publish`, `unpublish`, `delete` |
|
|
99
|
+
| `bf.ai_agents` | `list`, `get`, `preview` |
|
|
100
|
+
| `bfocus` (funções) | `sign_widget_identity`, `sign_widget_identity_v2` (locais, sem rede) |
|
|
101
|
+
|
|
84
102
|
## Clientes
|
|
85
103
|
|
|
86
104
|
```python
|
|
@@ -124,6 +142,174 @@ for i in bf.customers.interactions.list_all("ERP 1042"):
|
|
|
124
142
|
print(i["created_at"], i["content"])
|
|
125
143
|
```
|
|
126
144
|
|
|
145
|
+
## Pessoas
|
|
146
|
+
|
|
147
|
+
Pessoas são quem usa o sistema do seu cliente e abre chamados/conversas no widget. O
|
|
148
|
+
`external_id` da pessoa é o mesmo `user_external_id` que você assina para o widget — por isso
|
|
149
|
+
**não pode ter `:`**.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
p = bf.people.upsert(
|
|
153
|
+
"erp-1042", # o cliente
|
|
154
|
+
"app-77", # a pessoa (o usuário no seu sistema)
|
|
155
|
+
name="Paula Reis",
|
|
156
|
+
email="paula@padaria.example",
|
|
157
|
+
role="Financeiro",
|
|
158
|
+
is_primary=True,
|
|
159
|
+
extra_emails=["paula.reis@pessoal.example"], # somam aos que já existem
|
|
160
|
+
)
|
|
161
|
+
print(p["status"]) # "created", "updated" ou "unchanged"
|
|
162
|
+
|
|
163
|
+
for pessoa in bf.people.list("erp-1042"):
|
|
164
|
+
print(pessoa["name"], pessoa["access"])
|
|
165
|
+
|
|
166
|
+
bf.people.delete("erp-1042", "app-77") # retira o acesso
|
|
167
|
+
bf.people.upsert("erp-1042", "app-77", access=True) # devolve o acesso
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- **Nunca duplica**: se o e-mail (ou o telefone) já pertence a uma pessoa que chegou por e-mail
|
|
171
|
+
ou por outro sistema, ela é **adotada** e ganha o seu `external_id`.
|
|
172
|
+
- A mesma pessoa informada com **outro cliente** é transferida para ele.
|
|
173
|
+
- `delete` **retira o acesso** (devolve a pessoa com `access=False`); ela continua no histórico
|
|
174
|
+
de chamados e conversas. Um `upsert` com `access=True` devolve o acesso.
|
|
175
|
+
- Como nos outros upserts, só o que você passa muda; `name` é obrigatório ao criar.
|
|
176
|
+
|
|
177
|
+
## Lotes
|
|
178
|
+
|
|
179
|
+
`customers.batch` e `people.batch` criam/atualizam **até 500 itens por chamada** (`bfocus.BATCH_MAX`).
|
|
180
|
+
Acima disso a SDK levanta `ValueError` antes de chamar a API — ela **não** divide sozinha, porque
|
|
181
|
+
o `index` de cada resultado é a posição no lote que você enviou. Divida em fatias:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from bfocus import BATCH_MAX
|
|
185
|
+
|
|
186
|
+
clientes = [
|
|
187
|
+
{"external_id": "erp-1042", "name": "Padaria Estrela", "document": "12.345.678/0001-90"},
|
|
188
|
+
{"external_id": "erp-1043", "name": "Mercado Sol", "email": "contato@mercadosol.example"},
|
|
189
|
+
# ... quantos forem
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
for inicio in range(0, len(clientes), BATCH_MAX):
|
|
193
|
+
fatia = clientes[inicio:inicio + BATCH_MAX]
|
|
194
|
+
res = bf.customers.batch(fatia)
|
|
195
|
+
print(res["summary"]) # {"created": 1, "updated": 1, "unchanged": 0, "error": 0}
|
|
196
|
+
for r in res["results"]:
|
|
197
|
+
if r["status"] == "error":
|
|
198
|
+
item = fatia[r["index"]] # index = posição NESTA fatia
|
|
199
|
+
print("falhou:", item["external_id"], r["error"], r["code"]) # ex.: NAME_REQUIRED 422
|
|
200
|
+
elif r["merged_into"]:
|
|
201
|
+
print(fatia[r["index"]]["external_id"], "é extra; o principal é", r["merged_into"])
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
- Item de `customers.batch`: os campos do `customers.upsert` + `external_id` (obrigatório).
|
|
205
|
+
Chave ausente não muda; `None` limpa.
|
|
206
|
+
- Item de `people.batch` (plano): `customer_external_id` + `external_id` da pessoa + os campos
|
|
207
|
+
do `people.upsert`:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
bf.people.batch([
|
|
211
|
+
{"customer_external_id": "erp-1042", "external_id": "app-77",
|
|
212
|
+
"name": "Paula Reis", "email": "paula@padaria.example", "is_primary": True},
|
|
213
|
+
{"customer_external_id": "erp-1043", "external_id": "app-78", "name": "Rui Lima"},
|
|
214
|
+
])
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- Resultado por item: `index`, `status` (`created`, `updated`, `unchanged` ou `error`),
|
|
218
|
+
`external_id`, `merged_into` (o id enviado é extra: este é o principal), `error` (código
|
|
219
|
+
estável) e `code` (status HTTP que o item teria sozinho); mais `summary` com os contadores.
|
|
220
|
+
- **Um item com erro não desfaz os outros.** Lista vazia devolve o resultado zerado sem fazer
|
|
221
|
+
requisição.
|
|
222
|
+
|
|
223
|
+
## Identificadores extras
|
|
224
|
+
|
|
225
|
+
Ligue o id de **outro** sistema seu (CRM, e-commerce…) ao mesmo cadastro, sem duplicar. É
|
|
226
|
+
idempotente; se o id já pertence a outro cadastro, a API responde 409 `IDENTIFIER_IN_USE`
|
|
227
|
+
(`ConflictError`).
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
c = bf.customers.identifiers.add("erp-1042", "crm-88", label="CRM")
|
|
231
|
+
print(c["identifiers"]) # [{"external_id": "crm-88", "label": "CRM", "source": "api"}]
|
|
232
|
+
bf.customers.identifiers.remove("erp-1042", "crm-88")
|
|
233
|
+
|
|
234
|
+
bf.people.identifiers.add("app-77", "crm-p5") # sem label
|
|
235
|
+
bf.people.identifiers.remove("app-77", "crm-p5")
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Num lote, um item enviado com um id extra volta com o principal em `merged_into`.
|
|
239
|
+
|
|
240
|
+
## Sincronizar clientes e usuários do seu sistema
|
|
241
|
+
|
|
242
|
+
**Ids com o prefixo do sistema, sem `:`** — a assinatura do widget recusa `:`. Use `-` como
|
|
243
|
+
separador (`erp-1042` para clientes, `app-77` para pessoas) ou UUIDs puros. Assim vários
|
|
244
|
+
sistemas seus convivem no mesmo bFocus sem colisão.
|
|
245
|
+
|
|
246
|
+
**1. Carga inicial (no deploy da integração)**: clientes em fatias de 500 → vínculo com o produto →
|
|
247
|
+
pessoas em fatias de 500. Confira `summary["error"]` e registre os itens com erro.
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
import logging
|
|
251
|
+
import os
|
|
252
|
+
|
|
253
|
+
from bfocus import BATCH_MAX, Bfocus
|
|
254
|
+
|
|
255
|
+
log = logging.getLogger("bfocus-sync")
|
|
256
|
+
bf = Bfocus(os.environ["BFOCUS_API_KEY"]) # escopo customers:write
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def em_fatias(itens, rodada):
|
|
260
|
+
for inicio in range(0, len(itens), BATCH_MAX):
|
|
261
|
+
fatia = itens[inicio:inicio + BATCH_MAX]
|
|
262
|
+
res = rodada(fatia)
|
|
263
|
+
if res["summary"]["error"]:
|
|
264
|
+
for r in res["results"]:
|
|
265
|
+
if r["status"] == "error":
|
|
266
|
+
log.warning("bfocus: %s -> %s", fatia[r["index"]]["external_id"], r["error"])
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
clientes = [{"external_id": f"erp-{c.id}", "name": c.nome, "document": c.cnpj}
|
|
270
|
+
for c in Cliente.objects.all()]
|
|
271
|
+
em_fatias(clientes, bf.customers.batch)
|
|
272
|
+
|
|
273
|
+
for c in clientes:
|
|
274
|
+
bf.customers.products.attach(c["external_id"], "erp-cloud") # idempotente
|
|
275
|
+
|
|
276
|
+
pessoas = [{"customer_external_id": f"erp-{u.cliente_id}", "external_id": f"app-{u.id}",
|
|
277
|
+
"name": u.nome, "email": u.email}
|
|
278
|
+
for u in Usuario.objects.all()]
|
|
279
|
+
em_fatias(pessoas, bf.people.batch)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
**2. No dia a dia**: cada mudança no seu sistema vira uma chamada.
|
|
283
|
+
|
|
284
|
+
| No seu sistema | No bFocus |
|
|
285
|
+
| --- | --- |
|
|
286
|
+
| criou/alterou cliente | `bf.customers.upsert(...)` (+ `bf.customers.products.attach(...)` para ligar ao produto) |
|
|
287
|
+
| criou/alterou usuário | `bf.people.upsert(...)` |
|
|
288
|
+
| excluiu/desativou usuário | `bf.people.delete(...)` |
|
|
289
|
+
| excluiu cliente | `bf.customers.delete(...)` |
|
|
290
|
+
|
|
291
|
+
Se a resposta trouxer `merged_into`, atualize o id do seu lado.
|
|
292
|
+
|
|
293
|
+
**Nunca bloqueie a requisição do seu usuário esperando o bFocus**: enfileire (job/outbox) e
|
|
294
|
+
tente de novo com backoff. A SDK já repete 429/5xx com a mesma `Idempotency-Key`; a fila cobre
|
|
295
|
+
indisponibilidades longas.
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
# no seu código de aplicação: só enfileira
|
|
299
|
+
def usuario_salvo(usuario):
|
|
300
|
+
fila.enqueue(sincronizar_usuario, usuario.id)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# no worker (Celery, RQ, cron…): chama o bFocus; se falhar, a fila tenta de novo com backoff
|
|
304
|
+
def sincronizar_usuario(usuario_id):
|
|
305
|
+
u = Usuario.objects.get(id=usuario_id)
|
|
306
|
+
if not u.ativo:
|
|
307
|
+
bf.people.delete(f"erp-{u.cliente_id}", f"app-{u.id}")
|
|
308
|
+
return
|
|
309
|
+
bf.people.upsert(f"erp-{u.cliente_id}", f"app-{u.id}", name=u.nome, email=u.email,
|
|
310
|
+
access=True)
|
|
311
|
+
```
|
|
312
|
+
|
|
127
313
|
## Produtos
|
|
128
314
|
|
|
129
315
|
```python
|
|
@@ -290,7 +476,8 @@ except BfocusError as err:
|
|
|
290
476
|
```
|
|
291
477
|
|
|
292
478
|
Argumento inválido no seu código (chave vazia; parâmetro de caminho vazio, `"."` ou `".."`; `/`
|
|
293
|
-
no `external_id` de um artigo
|
|
479
|
+
no `external_id` de um artigo; mais de 500 itens num `customers.batch`/`people.batch`; `:` no
|
|
480
|
+
usuário da assinatura v2 do widget) levanta `ValueError`/`TypeError` na hora, sem chamar a API.
|
|
294
481
|
|
|
295
482
|
## Novas tentativas e idempotência
|
|
296
483
|
|
|
@@ -335,6 +522,26 @@ assinatura = sign_widget_identity(
|
|
|
335
522
|
# que abre o widget.
|
|
336
523
|
```
|
|
337
524
|
|
|
525
|
+
### Identidade v2 (com validade)
|
|
526
|
+
|
|
527
|
+
A v2 carrega o instante da assinatura e expira: a API aceita de **7 dias atrás até 5 minutos à
|
|
528
|
+
frente**. Gere a cada renderização da página e nunca guarde. Vai no mesmo lugar da v1 (o
|
|
529
|
+
`userHash` do widget); a v1 continua aceita.
|
|
530
|
+
|
|
531
|
+
```python
|
|
532
|
+
from bfocus import sign_widget_identity_v2
|
|
533
|
+
|
|
534
|
+
assinatura = sign_widget_identity_v2(
|
|
535
|
+
os.environ["BFOCUS_WIDGET_SECRET"],
|
|
536
|
+
user_external_id="app-77", # SEM ":" (é o separador; a SDK levanta ValueError)
|
|
537
|
+
customer_external_id="erp-1042",
|
|
538
|
+
)
|
|
539
|
+
# "v2.<ts>.<hex>": ts = segundos unix de agora; hex = HMAC-SHA256 de "v2:<ts>:app-77:erp-1042"
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Para testes, fixe o instante com `now=` (segundos unix `int`/`float` — não milissegundos — ou
|
|
543
|
+
`datetime`; sem fuso é tratado como UTC).
|
|
544
|
+
|
|
338
545
|
## Versões
|
|
339
546
|
|
|
340
547
|
**Fixe a versão exata** (`bfocus==0.1.0` no `requirements.txt` / `pyproject.toml`) e suba de uma
|
|
@@ -9,6 +9,7 @@ Zero dependências (só biblioteca padrão). Python 3.9+.
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from ._client import Bfocus
|
|
12
|
+
from ._resources import BATCH_MAX
|
|
12
13
|
from ._transport import CLIENT_ID, DEFAULT_BASE_URL
|
|
13
14
|
from ._version import __version__
|
|
14
15
|
from .errors import (
|
|
@@ -23,13 +24,15 @@ from .errors import (
|
|
|
23
24
|
ValidationError,
|
|
24
25
|
)
|
|
25
26
|
from .types import UNSET, Page
|
|
26
|
-
from .widget import sign_widget_identity
|
|
27
|
+
from .widget import sign_widget_identity, sign_widget_identity_v2
|
|
27
28
|
|
|
28
29
|
__all__ = [
|
|
29
30
|
"Bfocus",
|
|
30
31
|
"Page",
|
|
31
32
|
"UNSET",
|
|
32
33
|
"sign_widget_identity",
|
|
34
|
+
"sign_widget_identity_v2",
|
|
35
|
+
"BATCH_MAX",
|
|
33
36
|
"BfocusError",
|
|
34
37
|
"AuthenticationError",
|
|
35
38
|
"PermissionDeniedError",
|
|
@@ -4,9 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Any, Callable
|
|
6
6
|
|
|
7
|
-
from ._resources import AIAgents, Customers, KnowledgeBase, Products, ReleaseNotes
|
|
7
|
+
from ._resources import AIAgents, Customers, KnowledgeBase, People, Products, ReleaseNotes
|
|
8
8
|
from ._transport import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, Transport
|
|
9
|
-
from .widget import sign_widget_identity
|
|
9
|
+
from .widget import sign_widget_identity, sign_widget_identity_v2
|
|
10
10
|
|
|
11
11
|
__all__ = ["Bfocus"]
|
|
12
12
|
|
|
@@ -33,6 +33,8 @@ class Bfocus:
|
|
|
33
33
|
|
|
34
34
|
#: Também disponível como função do pacote: ``from bfocus import sign_widget_identity``.
|
|
35
35
|
sign_widget_identity = staticmethod(sign_widget_identity)
|
|
36
|
+
#: Também disponível como função do pacote: ``from bfocus import sign_widget_identity_v2``.
|
|
37
|
+
sign_widget_identity_v2 = staticmethod(sign_widget_identity_v2)
|
|
36
38
|
|
|
37
39
|
def __init__(
|
|
38
40
|
self,
|
|
@@ -57,8 +59,11 @@ class Bfocus:
|
|
|
57
59
|
timeout=float(timeout),
|
|
58
60
|
max_retries=max_retries,
|
|
59
61
|
)
|
|
60
|
-
#: Clientes (empresas), com ``.contacts``, ``.products`` e
|
|
62
|
+
#: Clientes (empresas), com ``.contacts``, ``.products``, ``.interactions`` e
|
|
63
|
+
#: ``.identifiers``.
|
|
61
64
|
self.customers = Customers(self._transport)
|
|
65
|
+
#: Pessoas dos clientes (quem abre chamados/conversas), com ``.identifiers``.
|
|
66
|
+
self.people = People(self._transport)
|
|
62
67
|
#: Catálogo de produtos.
|
|
63
68
|
self.products = Products(self._transport)
|
|
64
69
|
#: Release notes por produto.
|