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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bfocus
3
- Version: 0.1.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, produtos,
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) levanta `ValueError`/`TypeError` na hora, sem chamar a API.
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, produtos,
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) levanta `ValueError`/`TypeError` na hora, sem chamar a API.
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 ``.interactions``.
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.