bfocus 0.1.0__tar.gz → 0.2.1__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.2.1/PKG-INFO +708 -0
- bfocus-0.2.1/README.md +681 -0
- {bfocus-0.1.0 → bfocus-0.2.1}/bfocus/__init__.py +4 -1
- {bfocus-0.1.0 → bfocus-0.2.1}/bfocus/_client.py +8 -3
- {bfocus-0.1.0 → bfocus-0.2.1}/bfocus/_resources.py +367 -2
- {bfocus-0.1.0 → bfocus-0.2.1}/bfocus/_transport.py +7 -0
- {bfocus-0.1.0 → bfocus-0.2.1}/bfocus/_version.py +1 -1
- {bfocus-0.1.0 → bfocus-0.2.1}/bfocus/errors.py +9 -0
- {bfocus-0.1.0 → bfocus-0.2.1}/bfocus/types.py +133 -1
- bfocus-0.2.1/bfocus/widget.py +101 -0
- {bfocus-0.1.0 → bfocus-0.2.1}/pyproject.toml +1 -1
- {bfocus-0.1.0 → bfocus-0.2.1}/tests/fixtures/cases.json +1020 -0
- {bfocus-0.1.0 → bfocus-0.2.1}/tests/test_conformance.py +24 -1
- {bfocus-0.1.0 → bfocus-0.2.1}/tests/test_unit.py +157 -0
- bfocus-0.1.0/PKG-INFO +0 -384
- bfocus-0.1.0/README.md +0 -357
- bfocus-0.1.0/bfocus/widget.py +0 -36
- {bfocus-0.1.0 → bfocus-0.2.1}/.gitignore +0 -0
- {bfocus-0.1.0 → bfocus-0.2.1}/LICENSE +0 -0
- {bfocus-0.1.0 → bfocus-0.2.1}/bfocus/py.typed +0 -0
- {bfocus-0.1.0 → bfocus-0.2.1}/examples/quickstart.py +0 -0
- {bfocus-0.1.0 → bfocus-0.2.1}/tests/_support.py +0 -0
- {bfocus-0.1.0 → bfocus-0.2.1}/tests/test_version.py +0 -0
bfocus-0.2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bfocus
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: SDK oficial em Python da API pública do bFocus: clientes, produtos, release notes, base de conhecimento e agentes de IA.
|
|
5
|
+
Project-URL: Homepage, https://bfocus.com.br
|
|
6
|
+
Project-URL: Repository, https://github.com/bernisoftware/bfocus-python
|
|
7
|
+
Author-email: Berni Software <tecnologia@bernisoftware.com.br>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: api,atendimento,base de conhecimento,bfocus,release notes,sdk,suporte
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Natural Language :: Portuguese (Brazilian)
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# bfocus
|
|
29
|
+
|
|
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
|
+
|
|
33
|
+
Zero dependências (só biblioteca padrão) · Python 3.9+ · tipada (`py.typed`) · novas tentativas e
|
|
34
|
+
idempotência automáticas.
|
|
35
|
+
|
|
36
|
+
## Instalação
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install bfocus==0.1.0
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Hello world
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from bfocus import Bfocus
|
|
46
|
+
|
|
47
|
+
bf = Bfocus("bf_live_...")
|
|
48
|
+
|
|
49
|
+
cliente = bf.customers.upsert("ERP 1042", name="Padaria Estrela", email="contato@padaria.example")
|
|
50
|
+
print(cliente["id"], cliente["name"])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`upsert` cria ou atualiza pelo `external_id` do **seu** sistema — rodar de novo não duplica.
|
|
54
|
+
|
|
55
|
+
## Autenticação
|
|
56
|
+
|
|
57
|
+
Crie a chave no bFocus em **Integrações → Chaves de API**, marcando só os escopos de que a
|
|
58
|
+
integração precisa. Ela vai em `Authorization: Bearer <chave>` em toda requisição (a SDK cuida disso).
|
|
59
|
+
|
|
60
|
+
| Escopo | Permite |
|
|
61
|
+
| --- | --- |
|
|
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
|
+
| `products:read` | Ler o catálogo de produtos |
|
|
65
|
+
| `products:write` | Cadastrar, atualizar e arquivar produtos |
|
|
66
|
+
| `kb:read` | Ler e buscar artigos da base de conhecimento |
|
|
67
|
+
| `kb:write` | Criar, atualizar, publicar e excluir artigos da base de conhecimento |
|
|
68
|
+
| `ai_agents:read` | Ler os agentes de IA |
|
|
69
|
+
| `ai_agents:preview` | Testar a resposta de um agente de IA (consome IA da conta) |
|
|
70
|
+
| `release_notes:read` | Ler release notes |
|
|
71
|
+
| `release_notes:write` | Criar, atualizar e publicar release notes |
|
|
72
|
+
|
|
73
|
+
A chave legada (`bf_sk_…`) só alcança clientes (`customers:*`). Guarde a chave fora do código:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import os
|
|
77
|
+
from bfocus import Bfocus
|
|
78
|
+
|
|
79
|
+
bf = Bfocus(
|
|
80
|
+
os.environ["BFOCUS_API_KEY"],
|
|
81
|
+
base_url="https://api.bfocus.com.br", # padrão; em dev: "http://localhost:8000"
|
|
82
|
+
timeout=30, # segundos, por tentativa
|
|
83
|
+
max_retries=2, # novas tentativas além da primeira (0 desliga)
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Construir o cliente não faz nenhuma chamada de rede.
|
|
88
|
+
|
|
89
|
+
## Como os métodos funcionam
|
|
90
|
+
|
|
91
|
+
- **Retorno desembrulhado**: o método devolve o `data` da resposta, como `dict` (anotado com
|
|
92
|
+
`TypedDict` — veja `bfocus.types`). Campos novos que a API passar a devolver aparecem no dict;
|
|
93
|
+
nunca viram erro.
|
|
94
|
+
- **Listas paginadas** devolvem `Page` (`items`, `page`, `page_size`, `total`, `pages`), que é
|
|
95
|
+
iterável. Para percorrer tudo, use `list_all(...)` (clientes, interações, release notes e
|
|
96
|
+
artigos): um gerador preguiçoso que busca página por página (`page_size` padrão 100) e para na
|
|
97
|
+
última página ou numa página vazia.
|
|
98
|
+
- **Só o que você passa muda.** Os upserts são parciais: argumento não informado não é enviado;
|
|
99
|
+
`None` explícito vai como `null` e **limpa** o campo.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
bf.customers.upsert("ERP 1042", phone="11 3333-4444") # só o telefone muda
|
|
103
|
+
bf.customers.upsert("ERP 1042", phone=None) # apaga o telefone
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- Obrigatórios são posicionais; opcionais são nomeados. Toda chamada aceita `timeout=`; as de
|
|
107
|
+
escrita aceitam `idempotency_key=` (veja [Novas tentativas](#novas-tentativas-e-idempotência)).
|
|
108
|
+
- Datas (`updated_since`) aceitam `datetime` — convertido para ISO 8601 em UTC com `Z`; sem fuso é
|
|
109
|
+
tratado como UTC — ou string, que passa como veio.
|
|
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` | `list`, `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
|
+
|
|
129
|
+
## Clientes
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from datetime import datetime, timedelta, timezone
|
|
133
|
+
|
|
134
|
+
bf.customers.upsert(
|
|
135
|
+
"ERP 1042",
|
|
136
|
+
name="Padaria Estrela",
|
|
137
|
+
document="12.345.678/0001-90",
|
|
138
|
+
custom_fields=[{"key": "plano", "label": "Plano", "value": "ouro"}], # substitui a lista
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
cliente = bf.customers.get("ERP 1042")
|
|
142
|
+
|
|
143
|
+
pagina = bf.customers.list(q="padaria", page=1, page_size=50)
|
|
144
|
+
print(pagina.total, [c["name"] for c in pagina])
|
|
145
|
+
|
|
146
|
+
# Sincronização incremental: tudo o que mudou desde a última rodada, todas as páginas.
|
|
147
|
+
desde = datetime.now(timezone.utc) - timedelta(hours=1)
|
|
148
|
+
for c in bf.customers.list_all(updated_since=desde):
|
|
149
|
+
print(c["external_id"], c["updated_at"])
|
|
150
|
+
|
|
151
|
+
bf.customers.delete("ERP 1042")
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Contatos, produtos vinculados e interações
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
bf.customers.contacts.upsert("ERP 1042", "CT-1", name="Ana Souza", role="Financeiro",
|
|
158
|
+
email="ana@padaria.example", is_primary=True)
|
|
159
|
+
bf.customers.contacts.list("ERP 1042")
|
|
160
|
+
bf.customers.contacts.delete("ERP 1042", "CT-1")
|
|
161
|
+
|
|
162
|
+
bf.customers.products.attach("ERP 1042", "erp-cloud")
|
|
163
|
+
bf.customers.products.list("ERP 1042")
|
|
164
|
+
bf.customers.products.detach("ERP 1042", "erp-cloud")
|
|
165
|
+
|
|
166
|
+
bf.customers.interactions.create("ERP 1042", "Pedido 1042 faturado.",
|
|
167
|
+
author_email="carla@suaempresa.com.br")
|
|
168
|
+
for i in bf.customers.interactions.list_all("ERP 1042"):
|
|
169
|
+
print(i["created_at"], i["content"])
|
|
170
|
+
```
|
|
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
|
+
### Campos personalizados da pessoa
|
|
205
|
+
|
|
206
|
+
`custom_fields` leva o que só existe no seu sistema (matrícula, centro de custo, filial). É a
|
|
207
|
+
**exceção** ao "só o que vier muda": a lista enviada **substitui a lista inteira** — campo que
|
|
208
|
+
ficar de fora é **removido**. Mande sempre a lista que o seu sistema tem hoje; omitir o argumento não mexe
|
|
209
|
+
em nada, como em qualquer outro campo.
|
|
210
|
+
|
|
211
|
+
A `visibility` é decidida no bFocus e **preservada entre sincronizações** — por isso ela não vai
|
|
212
|
+
no envio, só volta na resposta: o seu ERP não rebaixa nem promove a exposição de um dado sem
|
|
213
|
+
querer.
|
|
214
|
+
|
|
215
|
+
Vale no upsert de pessoa, no lote de pessoas e na listagem de pessoas do cliente.
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
p = bf.people.upsert(
|
|
219
|
+
"erp-1042",
|
|
220
|
+
"app-77",
|
|
221
|
+
custom_fields=[ # a lista INTEIRA do seu sistema
|
|
222
|
+
{"key": "matricula", "label": "Matrícula", "value": "4471"},
|
|
223
|
+
{"key": "filial", "label": "Filial", "value": "Centro"},
|
|
224
|
+
],
|
|
225
|
+
)
|
|
226
|
+
for campo in p["custom_fields"]:
|
|
227
|
+
print(campo["key"], campo["value"], campo["visibility"]) # visibility vem do bFocus
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Apagar o e-mail ou o telefone da pessoa
|
|
231
|
+
|
|
232
|
+
Um contato gravado errado ficava preso para sempre: enquanto a ficha errada segurasse o
|
|
233
|
+
telefone, nenhum reenvio o soltava. `clear` apaga.
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
bf.people.upsert("erp-1042", "app-77", clear=["phone"]) # some o telefone
|
|
237
|
+
bf.people.upsert("erp-1042", "app-77", clear=["email", "phone"]) # some os dois
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Três regras que parecem contraintuitivas e são de propósito:
|
|
241
|
+
|
|
242
|
+
- **Apagar é explícito.** `phone=None`, `clear=[]` e não passar o argumento continuam
|
|
243
|
+
significando **"não mexe"** — a SDK não traduz `None` em `clear`. Fazer o `None` apagar
|
|
244
|
+
teria apagado, em silêncio e na primeira carga seguinte, o dado de todo sistema que manda
|
|
245
|
+
`None` para "não tenho esse valor".
|
|
246
|
+
- **Campo fora da lista é recusado, não ignorado**: hoje só `"email"` e `"phone"`; qualquer
|
|
247
|
+
outro devolve 422 `PERSON_CLEAR_FIELD_INVALID` (`ValidationError`).
|
|
248
|
+
- **Só se limpa a própria ficha.** Se você alcançou a pessoa por um identificador **extra**, a
|
|
249
|
+
API recusa com 409 `PERSON_CLEAR_NOT_OWN_RECORD` (`ConflictError`): apagar o contato de uma
|
|
250
|
+
ficha alcançada por apelido seria apagar dado de outro sistema. Para saber se o id que você
|
|
251
|
+
tem em mãos é o principal ou um extra, use `bf.people.identifiers.list(...)`.
|
|
252
|
+
|
|
253
|
+
Vale no `people.upsert` e no `people.batch` (`{"clear": ["phone"]}` no item).
|
|
254
|
+
|
|
255
|
+
### Contato já usado: um 409 que você consegue resolver
|
|
256
|
+
|
|
257
|
+
`PERSON_EMAIL_TAKEN` e `PERSON_PHONE_TAKEN` (409) não são "tente de novo": o e-mail (ou o
|
|
258
|
+
telefone) já é de outra pessoa da conta. O erro diz **de quem**, em `err.data` (a API repete o mesmo
|
|
259
|
+
detalhe em `err.validation`, por compatibilidade):
|
|
260
|
+
|
|
261
|
+
| campo | o que é |
|
|
262
|
+
| --- | --- |
|
|
263
|
+
| `field` | `email` ou `phone` — qual contato está tomado |
|
|
264
|
+
| `owner_external_id` | o identificador da pessoa que já usa esse contato |
|
|
265
|
+
| `owner_name` | o nome dela |
|
|
266
|
+
| `owner_customer_external_id` | o cliente a que ela pertence |
|
|
267
|
+
|
|
268
|
+
**É o `owner_customer_external_id` que decide a ação**, e os dois casos pedem coisas opostas:
|
|
269
|
+
|
|
270
|
+
- **mesmo cliente que você enviou** → é quase sempre a MESMA pessoa em dois sistemas. Uma pessoa
|
|
271
|
+
tem **N identificadores**: registre o seu como **extra** dela. A partir daí o seu id encontra
|
|
272
|
+
essa pessoa.
|
|
273
|
+
- **outro cliente** → ninguém decide sozinho a quem a pessoa pertence. Não force: registre o caso
|
|
274
|
+
e leve para quem conhece o cadastro. Unificar dois clientes é decisão de gente, não de um
|
|
275
|
+
casamento por e-mail.
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
from bfocus import ConflictError
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
bf.people.upsert("erp-1042", "app-77", name="Paula Reis", email="paula@padaria.example")
|
|
282
|
+
except ConflictError as err:
|
|
283
|
+
if err.code not in ("PERSON_EMAIL_TAKEN", "PERSON_PHONE_TAKEN"):
|
|
284
|
+
raise
|
|
285
|
+
dono = err.data
|
|
286
|
+
if dono.get("owner_customer_external_id") == "erp-1042":
|
|
287
|
+
# A mesma pessoa, com dois ids: o seu vira mais um identificador dela.
|
|
288
|
+
bf.people.identifiers.add(dono["owner_external_id"], "app-77", label="ERP")
|
|
289
|
+
else:
|
|
290
|
+
# Dono em OUTRO cliente: não decida sozinho — registre e leve para o cadastro.
|
|
291
|
+
avisar_cadastro(err.code, dono)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`PERSON_CONTACT_OTHER_CUSTOMER` (409) é o mesmo assunto pelo outro lado, e é **recusa
|
|
295
|
+
definitiva**: a API não move mais uma pessoa de um cliente para outro só porque o e-mail (ou o
|
|
296
|
+
telefone) casou. Repetir a chamada não resolve — trate como caso para o cadastro, nunca como
|
|
297
|
+
falha temporária.
|
|
298
|
+
|
|
299
|
+
## Lotes
|
|
300
|
+
|
|
301
|
+
`customers.batch` e `people.batch` criam/atualizam **até 500 itens por chamada** (`bfocus.BATCH_MAX`).
|
|
302
|
+
Acima disso a SDK levanta `ValueError` antes de chamar a API — ela **não** divide sozinha, porque
|
|
303
|
+
o `index` de cada resultado é a posição no lote que você enviou. Divida em fatias:
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
from bfocus import BATCH_MAX
|
|
307
|
+
|
|
308
|
+
clientes = [
|
|
309
|
+
{"external_id": "erp-1042", "name": "Padaria Estrela", "document": "12.345.678/0001-90"},
|
|
310
|
+
{"external_id": "erp-1043", "name": "Mercado Sol", "email": "contato@mercadosol.example"},
|
|
311
|
+
# ... quantos forem
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
for inicio in range(0, len(clientes), BATCH_MAX):
|
|
315
|
+
fatia = clientes[inicio:inicio + BATCH_MAX]
|
|
316
|
+
res = bf.customers.batch(fatia)
|
|
317
|
+
print(res["summary"]) # {"created": 1, "updated": 1, "unchanged": 0, "error": 0}
|
|
318
|
+
for r in res["results"]:
|
|
319
|
+
if r["status"] == "error":
|
|
320
|
+
item = fatia[r["index"]] # index = posição NESTA fatia
|
|
321
|
+
print("falhou:", item["external_id"], r["error"], r["code"]) # ex.: NAME_REQUIRED 422
|
|
322
|
+
elif r["merged_into"]:
|
|
323
|
+
print(fatia[r["index"]]["external_id"], "é extra; o principal é", r["merged_into"])
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
- Item de `customers.batch`: os campos do `customers.upsert` + `external_id` (obrigatório).
|
|
327
|
+
Chave ausente não muda; `None` limpa.
|
|
328
|
+
- Item de `people.batch` (plano): `customer_external_id` + `external_id` da pessoa + os campos
|
|
329
|
+
do `people.upsert`:
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
bf.people.batch([
|
|
333
|
+
{"customer_external_id": "erp-1042", "external_id": "app-77",
|
|
334
|
+
"name": "Paula Reis", "email": "paula@padaria.example", "is_primary": True},
|
|
335
|
+
{"customer_external_id": "erp-1043", "external_id": "app-78", "name": "Rui Lima"},
|
|
336
|
+
])
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
- Resultado por item: `index`, `status` (`created`, `updated`, `unchanged` ou `error`),
|
|
340
|
+
`external_id`, `merged_into` (o id enviado é extra: este é o principal), `error` (código
|
|
341
|
+
estável) e `code` (status HTTP que o item teria sozinho); mais `summary` com os contadores.
|
|
342
|
+
- **Um item com erro não desfaz os outros.** Lista vazia devolve o resultado zerado sem fazer
|
|
343
|
+
requisição.
|
|
344
|
+
|
|
345
|
+
## Identificadores extras
|
|
346
|
+
|
|
347
|
+
Ligue o id de **outro** sistema seu (CRM, e-commerce…) ao mesmo cadastro, sem duplicar. É
|
|
348
|
+
idempotente; se o id já pertence a outro cadastro, a API responde 409 `IDENTIFIER_IN_USE`
|
|
349
|
+
(`ConflictError`).
|
|
350
|
+
|
|
351
|
+
```python
|
|
352
|
+
c = bf.customers.identifiers.add("erp-1042", "crm-88", label="CRM")
|
|
353
|
+
print(c["identifiers"]) # [{"external_id": "crm-88", "label": "CRM", "source": "api"}]
|
|
354
|
+
bf.customers.identifiers.remove("erp-1042", "crm-88")
|
|
355
|
+
|
|
356
|
+
bf.people.identifiers.add("app-77", "crm-p5") # sem label
|
|
357
|
+
bf.people.identifiers.remove("app-77", "crm-p5")
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Num lote, um item enviado com um id extra volta com o principal em `merged_into`.
|
|
361
|
+
|
|
362
|
+
### Ler os identificadores da pessoa (para reconciliar)
|
|
363
|
+
|
|
364
|
+
`bf.people.list(...)` mostra só o identificador **principal** de cada pessoa. Quando dois
|
|
365
|
+
cadastros seus eram a mesma pessoa, um dos ids virou **extra** — e some da listagem sem ter
|
|
366
|
+
sumido do cadastro. É isso que faz a sua conferência fechar "633 de 636" sem explicar os 3.
|
|
367
|
+
|
|
368
|
+
`people.identifiers.list` é a fonte de verdade dessa conferência, e é **leitura**: antes dela
|
|
369
|
+
era preciso ESCREVER (tentar um `add`) para descobrir o que tinha acontecido. Aceita no
|
|
370
|
+
caminho o id principal **ou qualquer um dos extras**.
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
ids = bf.people.identifiers.list("crm-p5") # o id extra que "sumiu" da listagem
|
|
374
|
+
print(ids["external_id"]) # "app-77" — o principal do cadastro
|
|
375
|
+
for i in ids["identifiers"]:
|
|
376
|
+
print(i["external_id"], i["label"], i["source"])
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
## Sincronizar clientes e usuários do seu sistema
|
|
380
|
+
|
|
381
|
+
**Ids com o prefixo do sistema, sem `:`** — a assinatura do widget recusa `:`. Use `-` como
|
|
382
|
+
separador (`erp-1042` para clientes, `app-77` para pessoas) ou UUIDs puros. Assim vários
|
|
383
|
+
sistemas seus convivem no mesmo bFocus sem colisão.
|
|
384
|
+
|
|
385
|
+
**1. Carga inicial (no deploy da integração)**: clientes em fatias de 500 → vínculo com o produto →
|
|
386
|
+
pessoas em fatias de 500. Confira `summary["error"]` e registre os itens com erro.
|
|
387
|
+
|
|
388
|
+
```python
|
|
389
|
+
import logging
|
|
390
|
+
import os
|
|
391
|
+
|
|
392
|
+
from bfocus import BATCH_MAX, Bfocus
|
|
393
|
+
|
|
394
|
+
log = logging.getLogger("bfocus-sync")
|
|
395
|
+
bf = Bfocus(os.environ["BFOCUS_API_KEY"]) # escopo customers:write
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def em_fatias(itens, rodada):
|
|
399
|
+
for inicio in range(0, len(itens), BATCH_MAX):
|
|
400
|
+
fatia = itens[inicio:inicio + BATCH_MAX]
|
|
401
|
+
res = rodada(fatia)
|
|
402
|
+
if res["summary"]["error"]:
|
|
403
|
+
for r in res["results"]:
|
|
404
|
+
if r["status"] == "error":
|
|
405
|
+
log.warning("bfocus: %s -> %s", fatia[r["index"]]["external_id"], r["error"])
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
clientes = [{"external_id": f"erp-{c.id}", "name": c.nome, "document": c.cnpj}
|
|
409
|
+
for c in Cliente.objects.all()]
|
|
410
|
+
em_fatias(clientes, bf.customers.batch)
|
|
411
|
+
|
|
412
|
+
for c in clientes:
|
|
413
|
+
bf.customers.products.attach(c["external_id"], "erp-cloud") # idempotente
|
|
414
|
+
|
|
415
|
+
pessoas = [{"customer_external_id": f"erp-{u.cliente_id}", "external_id": f"app-{u.id}",
|
|
416
|
+
"name": u.nome, "email": u.email}
|
|
417
|
+
for u in Usuario.objects.all()]
|
|
418
|
+
em_fatias(pessoas, bf.people.batch)
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
**2. No dia a dia**: cada mudança no seu sistema vira uma chamada.
|
|
422
|
+
|
|
423
|
+
| No seu sistema | No bFocus |
|
|
424
|
+
| --- | --- |
|
|
425
|
+
| criou/alterou cliente | `bf.customers.upsert(...)` (+ `bf.customers.products.attach(...)` para ligar ao produto) |
|
|
426
|
+
| criou/alterou usuário | `bf.people.upsert(...)` |
|
|
427
|
+
| excluiu/desativou usuário | `bf.people.delete(...)` |
|
|
428
|
+
| excluiu cliente | `bf.customers.delete(...)` |
|
|
429
|
+
|
|
430
|
+
Se a resposta trouxer `merged_into`, atualize o id do seu lado.
|
|
431
|
+
|
|
432
|
+
**Nunca bloqueie a requisição do seu usuário esperando o bFocus**: enfileire (job/outbox) e
|
|
433
|
+
tente de novo com backoff. A SDK já repete 429/5xx com a mesma `Idempotency-Key`; a fila cobre
|
|
434
|
+
indisponibilidades longas.
|
|
435
|
+
|
|
436
|
+
```python
|
|
437
|
+
# no seu código de aplicação: só enfileira
|
|
438
|
+
def usuario_salvo(usuario):
|
|
439
|
+
fila.enqueue(sincronizar_usuario, usuario.id)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# no worker (Celery, RQ, cron…): chama o bFocus; se falhar, a fila tenta de novo com backoff
|
|
443
|
+
def sincronizar_usuario(usuario_id):
|
|
444
|
+
u = Usuario.objects.get(id=usuario_id)
|
|
445
|
+
if not u.ativo:
|
|
446
|
+
bf.people.delete(f"erp-{u.cliente_id}", f"app-{u.id}")
|
|
447
|
+
return
|
|
448
|
+
bf.people.upsert(f"erp-{u.cliente_id}", f"app-{u.id}", name=u.nome, email=u.email,
|
|
449
|
+
access=True)
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
## Produtos
|
|
453
|
+
|
|
454
|
+
```python
|
|
455
|
+
bf.products.upsert("erp-cloud", name="ERP Cloud", description="Gestão na nuvem", color="#6366F1")
|
|
456
|
+
bf.products.get("erp-cloud")
|
|
457
|
+
bf.products.list(include_inactive=True)
|
|
458
|
+
bf.products.archive("erp-cloud") # arquiva, não apaga
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
## Release notes — publicar direto do CI
|
|
462
|
+
|
|
463
|
+
Um passo no pipeline de release: cria ou atualiza a nota da versão e já publica.
|
|
464
|
+
|
|
465
|
+
```python
|
|
466
|
+
# scripts/publicar_release_note.py — roda no CI a cada tag
|
|
467
|
+
import os
|
|
468
|
+
import pathlib
|
|
469
|
+
|
|
470
|
+
from bfocus import Bfocus
|
|
471
|
+
|
|
472
|
+
bf = Bfocus(os.environ["BFOCUS_API_KEY"]) # escopo release_notes:write
|
|
473
|
+
versao = os.environ["GITHUB_REF_NAME"] # "v2.3.0" — o "v" na frente é aceito
|
|
474
|
+
|
|
475
|
+
bf.release_notes.upsert(
|
|
476
|
+
"erp-cloud",
|
|
477
|
+
versao,
|
|
478
|
+
title=f"Versão {versao.lstrip('v')}",
|
|
479
|
+
description_markdown=pathlib.Path(f"release-notes/{versao}.md").read_text(encoding="utf-8"),
|
|
480
|
+
audience="external", # "internal" | "external" | "both"
|
|
481
|
+
publish=True, # cria/atualiza e publica numa chamada só
|
|
482
|
+
)
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Rodar de novo para a mesma versão atualiza a nota (é upsert). Também há:
|
|
486
|
+
|
|
487
|
+
```python
|
|
488
|
+
bf.release_notes.get("erp-cloud", "2.3.0")
|
|
489
|
+
bf.release_notes.list("erp-cloud", published=False) # rascunhos
|
|
490
|
+
bf.release_notes.publish("erp-cloud", "2.3.0")
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
## Base de conhecimento — sincronizar a partir de arquivos Markdown
|
|
494
|
+
|
|
495
|
+
Mantenha a documentação no repositório e sincronize a cada push. `batch_upsert` aceita **qualquer
|
|
496
|
+
quantidade** de artigos: a SDK divide em lotes de 100 (o limite da API), envia em sequência e
|
|
497
|
+
devolve um único resultado.
|
|
498
|
+
|
|
499
|
+
```python
|
|
500
|
+
import os
|
|
501
|
+
import pathlib
|
|
502
|
+
|
|
503
|
+
from bfocus import Bfocus
|
|
504
|
+
|
|
505
|
+
bf = Bfocus(os.environ["BFOCUS_API_KEY"]) # escopos kb:read e kb:write
|
|
506
|
+
docs = pathlib.Path("docs")
|
|
507
|
+
|
|
508
|
+
artigos = []
|
|
509
|
+
for arquivo in sorted(docs.rglob("*.md")):
|
|
510
|
+
texto = arquivo.read_text(encoding="utf-8")
|
|
511
|
+
titulo = next((l[2:].strip() for l in texto.splitlines() if l.startswith("# ")), arquivo.stem)
|
|
512
|
+
artigos.append({
|
|
513
|
+
# id estável e SEM "/": o caminho do arquivo com ":" no lugar das barras.
|
|
514
|
+
# Aceita letras, números e . _ : ~ @ + = -
|
|
515
|
+
"external_id": "git:" + ":".join(arquivo.relative_to(docs).with_suffix("").parts),
|
|
516
|
+
"title": titulo,
|
|
517
|
+
"body_markdown": texto,
|
|
518
|
+
"product": "erp-cloud", # ou None (explícito) para um artigo global
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
res = bf.kb.articles.batch_upsert(artigos)
|
|
522
|
+
print(f"{res['created']} criados, {res['updated']} atualizados, "
|
|
523
|
+
f"{res['unchanged']} sem mudança, {res['failed']} com falha")
|
|
524
|
+
|
|
525
|
+
for r in res["results"]: # na mesma ordem enviada
|
|
526
|
+
if not r["ok"]:
|
|
527
|
+
print("falhou:", r["external_id"], r["error"]) # ex.: KB_ARTICLE_TITLE_REQUIRED
|
|
528
|
+
elif r["action"] in ("created", "updated"):
|
|
529
|
+
bf.kb.articles.publish(r["external_id"]) # publica o que entrou ou mudou
|
|
530
|
+
|
|
531
|
+
# Remove do bFocus o que saiu do repositório.
|
|
532
|
+
locais = {a["external_id"] for a in artigos}
|
|
533
|
+
for artigo in bf.kb.articles.list_all(product="erp-cloud"):
|
|
534
|
+
ext = artigo["external_id"] or ""
|
|
535
|
+
if ext.startswith("git:") and ext not in locais:
|
|
536
|
+
bf.kb.articles.delete(ext)
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Um item com problema não derruba os outros: ele volta com `ok=False` e o motivo em `error`. Para
|
|
540
|
+
publicar já no lote, mande `"status": "published"` em cada item. Artigo a artigo:
|
|
541
|
+
|
|
542
|
+
```python
|
|
543
|
+
bf.kb.articles.upsert("notion:emitir-nfse", title="Como emitir NFS-e",
|
|
544
|
+
body_markdown="# Passo a passo\n\n1. Abra o menu **Fiscal**",
|
|
545
|
+
product=None, status="published")
|
|
546
|
+
bf.kb.articles.get("notion:emitir-nfse") # artigo completo, com body_html
|
|
547
|
+
bf.kb.articles.list(status="draft", q="nota") # Page de resumos (sem body_html)
|
|
548
|
+
bf.kb.articles.unpublish("notion:emitir-nfse")
|
|
549
|
+
bf.kb.articles.delete("notion:emitir-nfse")
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### Busca
|
|
553
|
+
|
|
554
|
+
```python
|
|
555
|
+
for hit in bf.kb.search("como emitir nota fiscal", product="erp-cloud", limit=3):
|
|
556
|
+
print(hit["title"], "—", hit["excerpt"])
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
## Agentes de IA
|
|
560
|
+
|
|
561
|
+
```python
|
|
562
|
+
agentes = bf.ai_agents.list()
|
|
563
|
+
agente = bf.ai_agents.get(agentes[0]["id"])
|
|
564
|
+
|
|
565
|
+
resposta = bf.ai_agents.preview(
|
|
566
|
+
agente["id"],
|
|
567
|
+
"Como emito uma NFS-e?",
|
|
568
|
+
history=[{"role": "customer", "content": "Oi"},
|
|
569
|
+
{"role": "bot", "content": "Olá! Como posso ajudar?"}],
|
|
570
|
+
)
|
|
571
|
+
print(resposta["action"], resposta["answer_html"], resposta["sources"])
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
`preview` consome IA da conta (escopo `ai_agents:preview`).
|
|
575
|
+
|
|
576
|
+
## Erros
|
|
577
|
+
|
|
578
|
+
Qualquer resposta fora de 2xx levanta `BfocusError` (ou uma subclasse):
|
|
579
|
+
|
|
580
|
+
| Classe | Quando |
|
|
581
|
+
| --- | --- |
|
|
582
|
+
| `AuthenticationError` | 401 — chave ausente, inválida ou revogada |
|
|
583
|
+
| `PermissionDeniedError` | 403 — chave desligada, IP não liberado ou escopo faltando (`required_scope`) |
|
|
584
|
+
| `NotFoundError` | 404 |
|
|
585
|
+
| `ConflictError` | 409 — ex.: `KB_ARTICLE_EMPTY`, `AI_DISABLED` |
|
|
586
|
+
| `ValidationError` | 422 — motivos por campo em `validation` |
|
|
587
|
+
| `RateLimitError` | 429 — `retry_after` em segundos (depois de esgotar as novas tentativas) |
|
|
588
|
+
| `ServerError` | 5xx |
|
|
589
|
+
| `NetworkError` | conexão/timeout — `status == 0`, `code == "NETWORK_ERROR"` |
|
|
590
|
+
|
|
591
|
+
Além de `code`, `status`, `message`, `request_id`, `validation`, `retry_after` e
|
|
592
|
+
`required_scope`, o erro tem **`data`**: o `data` do corpo, com o detalhe estruturado que alguns
|
|
593
|
+
erros trazem (`{}` quando não há). É por ele que um 409 de contato tomado diz de **quem** é o
|
|
594
|
+
contato — veja [Pessoas](#pessoas).
|
|
595
|
+
|
|
596
|
+
**Decida pelo `code`** — ele é estável (`CUSTOMER_NOT_FOUND`, `INTEGRATION_SCOPE_MISSING`,
|
|
597
|
+
`VALIDATION_ERROR`…). O `message` é texto para humanos e pode mudar. Ao falar com o suporte,
|
|
598
|
+
informe o `request_id`: ele vem do corpo da resposta, senão do header `X-Request-Id`, senão é o id
|
|
599
|
+
que a própria SDK enviou (a API ecoa o do cliente) — então está sempre preenchido, inclusive em
|
|
600
|
+
`NetworkError`.
|
|
601
|
+
|
|
602
|
+
Se uma resposta 2xx chegar sem o envelope JSON da API (um proxy devolvendo HTML, corpo vazio), a
|
|
603
|
+
SDK não devolve `None` calado: levanta `BfocusError` com `code == "INVALID_RESPONSE"` e o status
|
|
604
|
+
recebido. Corpo de erro que não é JSON vira `code == "HTTP_<status>"`.
|
|
605
|
+
|
|
606
|
+
```python
|
|
607
|
+
from bfocus import BfocusError, NotFoundError
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
bf.customers.get("ERP 9999")
|
|
611
|
+
except NotFoundError:
|
|
612
|
+
print("não existe")
|
|
613
|
+
except BfocusError as err:
|
|
614
|
+
if err.code == "INTEGRATION_SCOPE_MISSING":
|
|
615
|
+
print("a chave não tem o escopo", err.required_scope)
|
|
616
|
+
elif err.code == "VALIDATION_ERROR":
|
|
617
|
+
print(err.validation) # {"email": "value is not a valid email address"}
|
|
618
|
+
else:
|
|
619
|
+
print(err.code, err.status, err.request_id)
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
Argumento inválido no seu código (chave vazia; parâmetro de caminho vazio, `"."` ou `".."`; `/`
|
|
623
|
+
no `external_id` de um artigo; mais de 500 itens num `customers.batch`/`people.batch`; `:` no
|
|
624
|
+
usuário da assinatura v2 do widget) levanta `ValueError`/`TypeError` na hora, sem chamar a API.
|
|
625
|
+
|
|
626
|
+
## Novas tentativas e idempotência
|
|
627
|
+
|
|
628
|
+
A SDK tenta de novo sozinha em **erro de rede/timeout, 429, 502, 503 e 504** — até `max_retries`
|
|
629
|
+
vezes (padrão 2). Espera o `Retry-After` quando a API manda (teto de 60 s); senão 0,5 s, 1 s, 2 s…
|
|
630
|
+
(teto de 8 s) + até 25% de variação aleatória. Um 500 ou outro 4xx volta na hora.
|
|
631
|
+
|
|
632
|
+
Toda escrita (POST/PUT/DELETE) leva um `Idempotency-Key`, e **a mesma chave vai em todas as
|
|
633
|
+
tentativas** da chamada: se a primeira chegou a executar e só a resposta se perdeu, a API devolve a
|
|
634
|
+
resposta original (`Idempotent-Replayed: true`) em vez de executar de novo. O `X-Request-Id` também
|
|
635
|
+
se repete, para o suporte ver as tentativas como uma chamada só.
|
|
636
|
+
|
|
637
|
+
Para que a proteção valha também quando o **seu** processo roda de novo (um job reexecutado), passe
|
|
638
|
+
uma chave derivada do evento:
|
|
639
|
+
|
|
640
|
+
```python
|
|
641
|
+
bf.customers.interactions.create(
|
|
642
|
+
"ERP 1042", "Pedido 1042 faturado.",
|
|
643
|
+
idempotency_key="pedido-1042-faturado",
|
|
644
|
+
)
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
A mesma chave com outra requisição volta `IDEMPOTENCY_KEY_REUSED`. No `batch_upsert`, o 1º lote
|
|
648
|
+
usa a sua chave como veio e os seguintes `"<chave>:2"`, `"<chave>:3"`… (sem chave, cada lote gera
|
|
649
|
+
a sua).
|
|
650
|
+
|
|
651
|
+
## Identidade do widget
|
|
652
|
+
|
|
653
|
+
Para o widget de atendimento reconhecer o usuário logado, o **seu backend** assina a identidade
|
|
654
|
+
dele com o segredo do widget (que nunca vai para o navegador). É local — sem rede e sem chave de API:
|
|
655
|
+
|
|
656
|
+
```python
|
|
657
|
+
import os
|
|
658
|
+
from bfocus import sign_widget_identity
|
|
659
|
+
|
|
660
|
+
assinatura = sign_widget_identity(
|
|
661
|
+
os.environ["BFOCUS_WIDGET_SECRET"],
|
|
662
|
+
user_external_id="USR-1", # o usuário no seu sistema
|
|
663
|
+
customer_external_id="ERP 1042", # a empresa (cliente) dele
|
|
664
|
+
)
|
|
665
|
+
# HMAC-SHA256 em hex minúsculo de "v1:USR-1:ERP 1042" — entregue junto dos dois ids à página
|
|
666
|
+
# que abre o widget.
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
### Identidade v2 (com validade)
|
|
670
|
+
|
|
671
|
+
A v2 carrega o instante da assinatura e expira: a API aceita de **7 dias atrás até 5 minutos à
|
|
672
|
+
frente**. Gere a cada renderização da página e nunca guarde. Vai no mesmo lugar da v1 (o
|
|
673
|
+
`userHash` do widget); a v1 continua aceita.
|
|
674
|
+
|
|
675
|
+
```python
|
|
676
|
+
from bfocus import sign_widget_identity_v2
|
|
677
|
+
|
|
678
|
+
assinatura = sign_widget_identity_v2(
|
|
679
|
+
os.environ["BFOCUS_WIDGET_SECRET"],
|
|
680
|
+
user_external_id="app-77", # SEM ":" (é o separador; a SDK levanta ValueError)
|
|
681
|
+
customer_external_id="erp-1042",
|
|
682
|
+
)
|
|
683
|
+
# "v2.<ts>.<hex>": ts = segundos unix de agora; hex = HMAC-SHA256 de "v2:<ts>:app-77:erp-1042"
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
Para testes, fixe o instante com `now=` (segundos unix `int`/`float` — não milissegundos — ou
|
|
687
|
+
`datetime`; sem fuso é tratado como UTC).
|
|
688
|
+
|
|
689
|
+
## Versões
|
|
690
|
+
|
|
691
|
+
**Fixe a versão exata** (`bfocus==0.1.0` no `requirements.txt` / `pyproject.toml`) e suba de uma
|
|
692
|
+
versão para a outra de propósito. Cada release declara se muda a superfície pública (`additive` ou
|
|
693
|
+
`breaking: …`), então dá para saber o que revisar antes de subir.
|
|
694
|
+
|
|
695
|
+
A SDK se identifica em toda requisição (`X-Bfocus-Client: bfocus-python/<versão>`): quando uma
|
|
696
|
+
correção exigir atualizar, o bFocus avisa as contas que rodam a versão afetada.
|
|
697
|
+
|
|
698
|
+
## Exemplo
|
|
699
|
+
|
|
700
|
+
Um script rodável está em [`examples/quickstart.py`](examples/quickstart.py):
|
|
701
|
+
|
|
702
|
+
```bash
|
|
703
|
+
BFOCUS_API_KEY=bf_live_... python examples/quickstart.py
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
## Licença
|
|
707
|
+
|
|
708
|
+
MIT © Berni Software
|