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 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