bfocus 0.1.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.
@@ -0,0 +1,8 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ __pycache__/
5
+ *.py[cod]
6
+ .venv/
7
+ venv/
8
+ .mypy_cache/
bfocus-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Berni Software
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
bfocus-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,384 @@
1
+ Metadata-Version: 2.5
2
+ Name: bfocus
3
+ Version: 0.1.0
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, produtos,
31
+ 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, produtos vinculados e interações |
63
+ | `customers:write` | Cadastrar, atualizar e excluir clientes, contatos e interações |
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
+ ## Clientes
112
+
113
+ ```python
114
+ from datetime import datetime, timedelta, timezone
115
+
116
+ bf.customers.upsert(
117
+ "ERP 1042",
118
+ name="Padaria Estrela",
119
+ document="12.345.678/0001-90",
120
+ custom_fields=[{"key": "plano", "label": "Plano", "value": "ouro"}], # substitui a lista
121
+ )
122
+
123
+ cliente = bf.customers.get("ERP 1042")
124
+
125
+ pagina = bf.customers.list(q="padaria", page=1, page_size=50)
126
+ print(pagina.total, [c["name"] for c in pagina])
127
+
128
+ # Sincronização incremental: tudo o que mudou desde a última rodada, todas as páginas.
129
+ desde = datetime.now(timezone.utc) - timedelta(hours=1)
130
+ for c in bf.customers.list_all(updated_since=desde):
131
+ print(c["external_id"], c["updated_at"])
132
+
133
+ bf.customers.delete("ERP 1042")
134
+ ```
135
+
136
+ ### Contatos, produtos vinculados e interações
137
+
138
+ ```python
139
+ bf.customers.contacts.upsert("ERP 1042", "CT-1", name="Ana Souza", role="Financeiro",
140
+ email="ana@padaria.example", is_primary=True)
141
+ bf.customers.contacts.list("ERP 1042")
142
+ bf.customers.contacts.delete("ERP 1042", "CT-1")
143
+
144
+ bf.customers.products.attach("ERP 1042", "erp-cloud")
145
+ bf.customers.products.list("ERP 1042")
146
+ bf.customers.products.detach("ERP 1042", "erp-cloud")
147
+
148
+ bf.customers.interactions.create("ERP 1042", "Pedido 1042 faturado.",
149
+ author_email="carla@suaempresa.com.br")
150
+ for i in bf.customers.interactions.list_all("ERP 1042"):
151
+ print(i["created_at"], i["content"])
152
+ ```
153
+
154
+ ## Produtos
155
+
156
+ ```python
157
+ bf.products.upsert("erp-cloud", name="ERP Cloud", description="Gestão na nuvem", color="#6366F1")
158
+ bf.products.get("erp-cloud")
159
+ bf.products.list(include_inactive=True)
160
+ bf.products.archive("erp-cloud") # arquiva, não apaga
161
+ ```
162
+
163
+ ## Release notes — publicar direto do CI
164
+
165
+ Um passo no pipeline de release: cria ou atualiza a nota da versão e já publica.
166
+
167
+ ```python
168
+ # scripts/publicar_release_note.py — roda no CI a cada tag
169
+ import os
170
+ import pathlib
171
+
172
+ from bfocus import Bfocus
173
+
174
+ bf = Bfocus(os.environ["BFOCUS_API_KEY"]) # escopo release_notes:write
175
+ versao = os.environ["GITHUB_REF_NAME"] # "v2.3.0" — o "v" na frente é aceito
176
+
177
+ bf.release_notes.upsert(
178
+ "erp-cloud",
179
+ versao,
180
+ title=f"Versão {versao.lstrip('v')}",
181
+ description_markdown=pathlib.Path(f"release-notes/{versao}.md").read_text(encoding="utf-8"),
182
+ audience="external", # "internal" | "external" | "both"
183
+ publish=True, # cria/atualiza e publica numa chamada só
184
+ )
185
+ ```
186
+
187
+ Rodar de novo para a mesma versão atualiza a nota (é upsert). Também há:
188
+
189
+ ```python
190
+ bf.release_notes.get("erp-cloud", "2.3.0")
191
+ bf.release_notes.list("erp-cloud", published=False) # rascunhos
192
+ bf.release_notes.publish("erp-cloud", "2.3.0")
193
+ ```
194
+
195
+ ## Base de conhecimento — sincronizar a partir de arquivos Markdown
196
+
197
+ Mantenha a documentação no repositório e sincronize a cada push. `batch_upsert` aceita **qualquer
198
+ quantidade** de artigos: a SDK divide em lotes de 100 (o limite da API), envia em sequência e
199
+ devolve um único resultado.
200
+
201
+ ```python
202
+ import os
203
+ import pathlib
204
+
205
+ from bfocus import Bfocus
206
+
207
+ bf = Bfocus(os.environ["BFOCUS_API_KEY"]) # escopos kb:read e kb:write
208
+ docs = pathlib.Path("docs")
209
+
210
+ artigos = []
211
+ for arquivo in sorted(docs.rglob("*.md")):
212
+ texto = arquivo.read_text(encoding="utf-8")
213
+ titulo = next((l[2:].strip() for l in texto.splitlines() if l.startswith("# ")), arquivo.stem)
214
+ artigos.append({
215
+ # id estável e SEM "/": o caminho do arquivo com ":" no lugar das barras.
216
+ # Aceita letras, números e . _ : ~ @ + = -
217
+ "external_id": "git:" + ":".join(arquivo.relative_to(docs).with_suffix("").parts),
218
+ "title": titulo,
219
+ "body_markdown": texto,
220
+ "product": "erp-cloud", # ou None (explícito) para um artigo global
221
+ })
222
+
223
+ res = bf.kb.articles.batch_upsert(artigos)
224
+ print(f"{res['created']} criados, {res['updated']} atualizados, "
225
+ f"{res['unchanged']} sem mudança, {res['failed']} com falha")
226
+
227
+ for r in res["results"]: # na mesma ordem enviada
228
+ if not r["ok"]:
229
+ print("falhou:", r["external_id"], r["error"]) # ex.: KB_ARTICLE_TITLE_REQUIRED
230
+ elif r["action"] in ("created", "updated"):
231
+ bf.kb.articles.publish(r["external_id"]) # publica o que entrou ou mudou
232
+
233
+ # Remove do bFocus o que saiu do repositório.
234
+ locais = {a["external_id"] for a in artigos}
235
+ for artigo in bf.kb.articles.list_all(product="erp-cloud"):
236
+ ext = artigo["external_id"] or ""
237
+ if ext.startswith("git:") and ext not in locais:
238
+ bf.kb.articles.delete(ext)
239
+ ```
240
+
241
+ Um item com problema não derruba os outros: ele volta com `ok=False` e o motivo em `error`. Para
242
+ publicar já no lote, mande `"status": "published"` em cada item. Artigo a artigo:
243
+
244
+ ```python
245
+ bf.kb.articles.upsert("notion:emitir-nfse", title="Como emitir NFS-e",
246
+ body_markdown="# Passo a passo\n\n1. Abra o menu **Fiscal**",
247
+ product=None, status="published")
248
+ bf.kb.articles.get("notion:emitir-nfse") # artigo completo, com body_html
249
+ bf.kb.articles.list(status="draft", q="nota") # Page de resumos (sem body_html)
250
+ bf.kb.articles.unpublish("notion:emitir-nfse")
251
+ bf.kb.articles.delete("notion:emitir-nfse")
252
+ ```
253
+
254
+ ### Busca
255
+
256
+ ```python
257
+ for hit in bf.kb.search("como emitir nota fiscal", product="erp-cloud", limit=3):
258
+ print(hit["title"], "—", hit["excerpt"])
259
+ ```
260
+
261
+ ## Agentes de IA
262
+
263
+ ```python
264
+ agentes = bf.ai_agents.list()
265
+ agente = bf.ai_agents.get(agentes[0]["id"])
266
+
267
+ resposta = bf.ai_agents.preview(
268
+ agente["id"],
269
+ "Como emito uma NFS-e?",
270
+ history=[{"role": "customer", "content": "Oi"},
271
+ {"role": "bot", "content": "Olá! Como posso ajudar?"}],
272
+ )
273
+ print(resposta["action"], resposta["answer_html"], resposta["sources"])
274
+ ```
275
+
276
+ `preview` consome IA da conta (escopo `ai_agents:preview`).
277
+
278
+ ## Erros
279
+
280
+ Qualquer resposta fora de 2xx levanta `BfocusError` (ou uma subclasse):
281
+
282
+ | Classe | Quando |
283
+ | --- | --- |
284
+ | `AuthenticationError` | 401 — chave ausente, inválida ou revogada |
285
+ | `PermissionDeniedError` | 403 — chave desligada, IP não liberado ou escopo faltando (`required_scope`) |
286
+ | `NotFoundError` | 404 |
287
+ | `ConflictError` | 409 — ex.: `KB_ARTICLE_EMPTY`, `AI_DISABLED` |
288
+ | `ValidationError` | 422 — motivos por campo em `validation` |
289
+ | `RateLimitError` | 429 — `retry_after` em segundos (depois de esgotar as novas tentativas) |
290
+ | `ServerError` | 5xx |
291
+ | `NetworkError` | conexão/timeout — `status == 0`, `code == "NETWORK_ERROR"` |
292
+
293
+ **Decida pelo `code`** — ele é estável (`CUSTOMER_NOT_FOUND`, `INTEGRATION_SCOPE_MISSING`,
294
+ `VALIDATION_ERROR`…). O `message` é texto para humanos e pode mudar. Ao falar com o suporte,
295
+ informe o `request_id`: ele vem do corpo da resposta, senão do header `X-Request-Id`, senão é o id
296
+ que a própria SDK enviou (a API ecoa o do cliente) — então está sempre preenchido, inclusive em
297
+ `NetworkError`.
298
+
299
+ Se uma resposta 2xx chegar sem o envelope JSON da API (um proxy devolvendo HTML, corpo vazio), a
300
+ SDK não devolve `None` calado: levanta `BfocusError` com `code == "INVALID_RESPONSE"` e o status
301
+ recebido. Corpo de erro que não é JSON vira `code == "HTTP_<status>"`.
302
+
303
+ ```python
304
+ from bfocus import BfocusError, NotFoundError
305
+
306
+ try:
307
+ bf.customers.get("ERP 9999")
308
+ except NotFoundError:
309
+ print("não existe")
310
+ except BfocusError as err:
311
+ if err.code == "INTEGRATION_SCOPE_MISSING":
312
+ print("a chave não tem o escopo", err.required_scope)
313
+ elif err.code == "VALIDATION_ERROR":
314
+ print(err.validation) # {"email": "value is not a valid email address"}
315
+ else:
316
+ print(err.code, err.status, err.request_id)
317
+ ```
318
+
319
+ Argumento inválido no seu código (chave vazia; parâmetro de caminho vazio, `"."` ou `".."`; `/`
320
+ no `external_id` de um artigo) levanta `ValueError`/`TypeError` na hora, sem chamar a API.
321
+
322
+ ## Novas tentativas e idempotência
323
+
324
+ A SDK tenta de novo sozinha em **erro de rede/timeout, 429, 502, 503 e 504** — até `max_retries`
325
+ 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…
326
+ (teto de 8 s) + até 25% de variação aleatória. Um 500 ou outro 4xx volta na hora.
327
+
328
+ Toda escrita (POST/PUT/DELETE) leva um `Idempotency-Key`, e **a mesma chave vai em todas as
329
+ tentativas** da chamada: se a primeira chegou a executar e só a resposta se perdeu, a API devolve a
330
+ resposta original (`Idempotent-Replayed: true`) em vez de executar de novo. O `X-Request-Id` também
331
+ se repete, para o suporte ver as tentativas como uma chamada só.
332
+
333
+ Para que a proteção valha também quando o **seu** processo roda de novo (um job reexecutado), passe
334
+ uma chave derivada do evento:
335
+
336
+ ```python
337
+ bf.customers.interactions.create(
338
+ "ERP 1042", "Pedido 1042 faturado.",
339
+ idempotency_key="pedido-1042-faturado",
340
+ )
341
+ ```
342
+
343
+ A mesma chave com outra requisição volta `IDEMPOTENCY_KEY_REUSED`. No `batch_upsert`, o 1º lote
344
+ usa a sua chave como veio e os seguintes `"<chave>:2"`, `"<chave>:3"`… (sem chave, cada lote gera
345
+ a sua).
346
+
347
+ ## Identidade do widget
348
+
349
+ Para o widget de atendimento reconhecer o usuário logado, o **seu backend** assina a identidade
350
+ dele com o segredo do widget (que nunca vai para o navegador). É local — sem rede e sem chave de API:
351
+
352
+ ```python
353
+ import os
354
+ from bfocus import sign_widget_identity
355
+
356
+ assinatura = sign_widget_identity(
357
+ os.environ["BFOCUS_WIDGET_SECRET"],
358
+ user_external_id="USR-1", # o usuário no seu sistema
359
+ customer_external_id="ERP 1042", # a empresa (cliente) dele
360
+ )
361
+ # HMAC-SHA256 em hex minúsculo de "v1:USR-1:ERP 1042" — entregue junto dos dois ids à página
362
+ # que abre o widget.
363
+ ```
364
+
365
+ ## Versões
366
+
367
+ **Fixe a versão exata** (`bfocus==0.1.0` no `requirements.txt` / `pyproject.toml`) e suba de uma
368
+ versão para a outra de propósito. Cada release declara se muda a superfície pública (`additive` ou
369
+ `breaking: …`), então dá para saber o que revisar antes de subir.
370
+
371
+ A SDK se identifica em toda requisição (`X-Bfocus-Client: bfocus-python/<versão>`): quando uma
372
+ correção exigir atualizar, o bFocus avisa as contas que rodam a versão afetada.
373
+
374
+ ## Exemplo
375
+
376
+ Um script rodável está em [`examples/quickstart.py`](examples/quickstart.py):
377
+
378
+ ```bash
379
+ BFOCUS_API_KEY=bf_live_... python examples/quickstart.py
380
+ ```
381
+
382
+ ## Licença
383
+
384
+ MIT © Berni Software