bfocus 0.1.0
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +405 -0
- data/lib/bfocus/client.rb +82 -0
- data/lib/bfocus/codec.rb +113 -0
- data/lib/bfocus/errors.rb +104 -0
- data/lib/bfocus/page.rb +94 -0
- data/lib/bfocus/resources/ai_agents.rb +37 -0
- data/lib/bfocus/resources/base.rb +66 -0
- data/lib/bfocus/resources/customers.rb +174 -0
- data/lib/bfocus/resources/knowledge_base.rb +176 -0
- data/lib/bfocus/resources/products.rb +43 -0
- data/lib/bfocus/resources/release_notes.rb +70 -0
- data/lib/bfocus/transport.rb +261 -0
- data/lib/bfocus/unset.rb +27 -0
- data/lib/bfocus/version.rb +7 -0
- data/lib/bfocus/widget.rb +30 -0
- data/lib/bfocus.rb +34 -0
- metadata +64 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bfocus
|
|
4
|
+
# Erro devolvido pela API do bFocus (ou de rede, em {NetworkError}).
|
|
5
|
+
#
|
|
6
|
+
# Toda resposta fora de 2xx vira um `Bfocus::Error` — ou a subclasse do status. **Use
|
|
7
|
+
# {#code} na sua lógica**: ele é estável (`CUSTOMER_NOT_FOUND`, `INTEGRATION_SCOPE_MISSING`…).
|
|
8
|
+
# O {#message} é texto para humanos e pode mudar.
|
|
9
|
+
#
|
|
10
|
+
# Argumento inválido no seu código (chave vazia, parâmetro de caminho vazio/"."/"..", `/`
|
|
11
|
+
# no `external_id` de artigo) NÃO é `Bfocus::Error`: é `ArgumentError`/`TypeError`, na hora.
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
# @return [String] código estável: `body.error`, senão `body.message`, senão
|
|
14
|
+
# `HTTP_<status>` (corpo não-JSON); `INVALID_RESPONSE` quando um 2xx chega sem o
|
|
15
|
+
# envelope JSON da API; `NETWORK_ERROR` em falha de rede.
|
|
16
|
+
attr_reader :code
|
|
17
|
+
# @return [Integer] status HTTP (`0` em erro de rede).
|
|
18
|
+
attr_reader :status
|
|
19
|
+
# @return [String, nil] `request_id` do corpo, senão o header `X-Request-Id`, senão o
|
|
20
|
+
# `X-Request-Id` que a SDK enviou (a API ecoa o do cliente). Informe-o ao suporte.
|
|
21
|
+
attr_reader :request_id
|
|
22
|
+
# @return [Hash{String=>String}] motivos por campo (erros de validação); `{}` quando não há.
|
|
23
|
+
attr_reader :validation
|
|
24
|
+
# @return [Integer, Float, nil] segundos do header `Retry-After` (só em 429).
|
|
25
|
+
attr_reader :retry_after
|
|
26
|
+
# @return [String, nil] escopo que faltou na chave (header `X-Required-Scope`, só em 403).
|
|
27
|
+
attr_reader :required_scope
|
|
28
|
+
# @return [Hash, String, nil] corpo da resposta decodificado, ou o texto cru se não é JSON.
|
|
29
|
+
attr_reader :body
|
|
30
|
+
|
|
31
|
+
def initialize(message = nil, code: nil, status: 0, request_id: nil, validation: nil,
|
|
32
|
+
retry_after: nil, required_scope: nil, body: nil)
|
|
33
|
+
@code = code
|
|
34
|
+
@status = status
|
|
35
|
+
@request_id = request_id
|
|
36
|
+
@validation = validation.is_a?(Hash) ? validation.dup : {}
|
|
37
|
+
@retry_after = retry_after
|
|
38
|
+
@required_scope = required_scope
|
|
39
|
+
@body = body
|
|
40
|
+
text = message || code || self.class.name
|
|
41
|
+
text = "#{text} [request_id=#{request_id}]" if request_id && !text.include?(request_id)
|
|
42
|
+
super(text)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def inspect
|
|
46
|
+
"#<#{self.class.name} code=#{code.inspect} status=#{status.inspect} " \
|
|
47
|
+
"request_id=#{request_id.inspect}>"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
STATUS_CLASSES = {} # preenchido abaixo, depois das subclasses
|
|
51
|
+
private_constant :STATUS_CLASSES
|
|
52
|
+
|
|
53
|
+
# Classe de erro para um status HTTP (5xx → {ServerError}; sem classe própria → a base).
|
|
54
|
+
# @param status [Integer]
|
|
55
|
+
# @return [Class]
|
|
56
|
+
def self.class_for(status)
|
|
57
|
+
return STATUS_CLASSES[status] if STATUS_CLASSES.key?(status)
|
|
58
|
+
return ServerError if status >= 500 && status <= 599
|
|
59
|
+
|
|
60
|
+
Error
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# 401 — chave ausente, inválida ou revogada.
|
|
65
|
+
class AuthenticationError < Error; end
|
|
66
|
+
|
|
67
|
+
# 403 — chave desligada, IP não liberado, escopo faltando ({#required_scope}) ou módulo não
|
|
68
|
+
# contratado (`MODULE_NOT_CONTRACTED`: base de conhecimento e agentes de IA exigem o módulo
|
|
69
|
+
# de Atendimento).
|
|
70
|
+
class PermissionDeniedError < Error; end
|
|
71
|
+
|
|
72
|
+
# 404 — o recurso (ou a conta) não existe.
|
|
73
|
+
class NotFoundError < Error; end
|
|
74
|
+
|
|
75
|
+
# 409 — o estado atual impede a operação (ex.: `KB_ARTICLE_EMPTY`, `AI_DISABLED`).
|
|
76
|
+
class ConflictError < Error; end
|
|
77
|
+
|
|
78
|
+
# 422 — corpo ou parâmetro inválido; os motivos por campo estão em {#validation}.
|
|
79
|
+
class ValidationError < Error; end
|
|
80
|
+
|
|
81
|
+
# 429 — limite de requisições da chave; {#retry_after} diz quanto esperar.
|
|
82
|
+
class RateLimitError < Error; end
|
|
83
|
+
|
|
84
|
+
# 5xx — erro do lado da API. Informe o {#request_id}.
|
|
85
|
+
class ServerError < Error; end
|
|
86
|
+
|
|
87
|
+
# Falha de conexão ou timeout (`status == 0`, `code == "NETWORK_ERROR"`).
|
|
88
|
+
class NetworkError < Error
|
|
89
|
+
def initialize(message = nil, request_id: nil, **_ignored)
|
|
90
|
+
super(message || "NETWORK_ERROR", code: "NETWORK_ERROR", status: 0, request_id: request_id)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class Error
|
|
95
|
+
STATUS_CLASSES.merge!(
|
|
96
|
+
401 => AuthenticationError,
|
|
97
|
+
403 => PermissionDeniedError,
|
|
98
|
+
404 => NotFoundError,
|
|
99
|
+
409 => ConflictError,
|
|
100
|
+
422 => ValidationError,
|
|
101
|
+
429 => RateLimitError
|
|
102
|
+
).freeze
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/bfocus/page.rb
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bfocus
|
|
4
|
+
# Uma página de resultados das listas paginadas (`customers.list`, `interactions.list`,
|
|
5
|
+
# `release_notes.list`, `kb.articles.list`). É `Enumerable` sobre {#items}.
|
|
6
|
+
#
|
|
7
|
+
# Para percorrer tudo sem controlar páginas, use o `list_all(...)` do recurso.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# page = client.customers.list(q: "padaria", page_size: 50)
|
|
11
|
+
# page.total # => 123
|
|
12
|
+
# page.map { |c| c["name"] }
|
|
13
|
+
# page.next_page? # => true
|
|
14
|
+
class Page
|
|
15
|
+
include Enumerable
|
|
16
|
+
|
|
17
|
+
# @return [Array<Hash{String=>Object}>] itens da página (Hash com chaves string).
|
|
18
|
+
attr_reader :items
|
|
19
|
+
# @return [Integer] número desta página (1-based).
|
|
20
|
+
attr_reader :page
|
|
21
|
+
# @return [Integer] tamanho de página usado pela API.
|
|
22
|
+
attr_reader :page_size
|
|
23
|
+
# @return [Integer] total de itens (todas as páginas).
|
|
24
|
+
attr_reader :total
|
|
25
|
+
# @return [Integer] total de páginas.
|
|
26
|
+
attr_reader :pages
|
|
27
|
+
|
|
28
|
+
def initialize(items:, page:, page_size:, total:, pages:)
|
|
29
|
+
@items = items
|
|
30
|
+
@page = page
|
|
31
|
+
@page_size = page_size
|
|
32
|
+
@total = total
|
|
33
|
+
@pages = pages
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Monta a página a partir do `data` + `pagination` do envelope da API.
|
|
37
|
+
# @api private
|
|
38
|
+
def self.from_response(data, pagination)
|
|
39
|
+
items = data.is_a?(Array) ? data : []
|
|
40
|
+
unless pagination.is_a?(Hash) # a API sempre manda; defensivo para não quebrar a iteração
|
|
41
|
+
return new(items: items, page: 1, page_size: items.size, total: items.size, pages: 1)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
new(
|
|
45
|
+
items: items,
|
|
46
|
+
page: (pagination["page"] || 1).to_i,
|
|
47
|
+
page_size: (pagination["page_size"] || items.size).to_i,
|
|
48
|
+
total: (pagination["total"] || items.size).to_i,
|
|
49
|
+
pages: (pagination["pages"] || 1).to_i
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def each(&block)
|
|
54
|
+
return enum_for(:each) { size } unless block
|
|
55
|
+
|
|
56
|
+
items.each(&block)
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Integer] quantidade de itens NESTA página.
|
|
61
|
+
def size
|
|
62
|
+
items.size
|
|
63
|
+
end
|
|
64
|
+
alias length size
|
|
65
|
+
|
|
66
|
+
def empty?
|
|
67
|
+
items.empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Boolean] `true` se existe página depois desta.
|
|
71
|
+
def next_page?
|
|
72
|
+
page < pages
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Hash{Symbol=>Object}] `{items:, page:, page_size:, total:, pages:}`
|
|
76
|
+
def to_h
|
|
77
|
+
{ items: items, page: page, page_size: page_size, total: total, pages: pages }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def ==(other)
|
|
81
|
+
other.is_a?(Page) && to_h == other.to_h
|
|
82
|
+
end
|
|
83
|
+
alias eql? ==
|
|
84
|
+
|
|
85
|
+
def hash
|
|
86
|
+
to_h.hash
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def inspect
|
|
90
|
+
"#<Bfocus::Page page=#{page}/#{pages} page_size=#{page_size} total=#{total} " \
|
|
91
|
+
"items=#{items.size}>"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bfocus
|
|
4
|
+
module Resources
|
|
5
|
+
# Agentes de IA — `client.ai_agents`. Exige o módulo de Atendimento.
|
|
6
|
+
#
|
|
7
|
+
# Agente: `"id"`, `"name"`, `"product"` (`{"id", "slug", "name", "is_active"}`), `"active"`,
|
|
8
|
+
# `"persona"`, `"scope"`, `"avatar_url"`, `"created_at"`, `"updated_at"`.
|
|
9
|
+
class AIAgents < Base
|
|
10
|
+
# Agentes de IA da conta. `GET /ai-agents`
|
|
11
|
+
# @return [Array<Hash>]
|
|
12
|
+
def list(timeout: nil)
|
|
13
|
+
call("GET", "/ai-agents", timeout: timeout)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Um agente. `GET /ai-agents/{agent_id}`
|
|
17
|
+
# @return [Hash]
|
|
18
|
+
def get(agent_id, timeout: nil)
|
|
19
|
+
call("GET", "/ai-agents/#{segment(agent_id, 'agent_id')}", timeout: timeout)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Testa a resposta do agente a uma mensagem (consome IA da conta).
|
|
23
|
+
# `POST /ai-agents/{agent_id}/preview`
|
|
24
|
+
#
|
|
25
|
+
# @param history [Array<Hash>] turnos anteriores, `[{role: "customer"|"bot", content: "…"}]`
|
|
26
|
+
# (até 20).
|
|
27
|
+
# @return [Hash] `"action"` (`answer`, `handoff` ou `refuse`), `"answer_html"`,
|
|
28
|
+
# `"escalated"`, `"refused"`, `"handoff_reason"`, `"confidence"`, `"topic"`, `"guards"`,
|
|
29
|
+
# `"citations"`, `"sources"`, `"collected"`, `"missing"`.
|
|
30
|
+
def preview(agent_id, message, history: UNSET, idempotency_key: nil, timeout: nil)
|
|
31
|
+
body = compact("message" => message, "history" => history)
|
|
32
|
+
call("POST", "/ai-agents/#{segment(agent_id, 'agent_id')}/preview",
|
|
33
|
+
body: body, idempotency_key: idempotency_key, timeout: timeout)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bfocus
|
|
4
|
+
# Recursos da API pública: `customers`, `products`, `release_notes`, `kb`, `ai_agents`.
|
|
5
|
+
#
|
|
6
|
+
# Convenções (iguais em todos os métodos):
|
|
7
|
+
#
|
|
8
|
+
# * Obrigatórios são posicionais; opcionais são keyword args.
|
|
9
|
+
# * Campos de corpo opcionais têm padrão {Bfocus::UNSET} — não informado = não enviado.
|
|
10
|
+
# `nil` explícito vai como `null` e **limpa** o campo na API.
|
|
11
|
+
# * Filtros de query com `nil` são omitidos.
|
|
12
|
+
# * Toda chamada aceita `timeout:` (segundos, por tentativa); as escritas aceitam
|
|
13
|
+
# `idempotency_key:` (senão a SDK gera uma e a repete nas novas tentativas).
|
|
14
|
+
# * O retorno é o `data` da resposta: `Hash`/`Array` com chaves **string**, exatamente como a
|
|
15
|
+
# API devolve (campos novos aparecem sem quebrar nada). Listas paginadas devolvem {Page}.
|
|
16
|
+
module Resources
|
|
17
|
+
# @api private
|
|
18
|
+
class Base
|
|
19
|
+
def initialize(transport)
|
|
20
|
+
@transport = transport
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def inspect
|
|
24
|
+
"#<#{self.class.name}>"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def call(method, path, query: nil, body: nil, idempotency_key: nil, timeout: nil)
|
|
30
|
+
data, _pagination = @transport.request(
|
|
31
|
+
method, path,
|
|
32
|
+
query: query, body: body, idempotency_key: idempotency_key, timeout: timeout
|
|
33
|
+
)
|
|
34
|
+
data
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def paged(path, query, timeout)
|
|
38
|
+
data, pagination = @transport.request("GET", path, query: query, timeout: timeout)
|
|
39
|
+
Page.from_response(data, pagination)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Enumerator::Lazy que busca página por página (cada uma é uma chamada lógica nova) e
|
|
43
|
+
# para quando `page >= pages` ou a página vem vazia.
|
|
44
|
+
def iterate(&fetch)
|
|
45
|
+
Enumerator.new do |yielder|
|
|
46
|
+
number = 1
|
|
47
|
+
while true # rubocop:disable Style/InfiniteLoop
|
|
48
|
+
current = fetch.call(number)
|
|
49
|
+
current.items.each { |item| yielder << item }
|
|
50
|
+
break if current.items.empty? || current.page >= current.pages
|
|
51
|
+
|
|
52
|
+
number += 1
|
|
53
|
+
end
|
|
54
|
+
end.lazy
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def segment(value, name)
|
|
58
|
+
Codec.path_segment(value, name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def compact(fields)
|
|
62
|
+
Codec.compact(fields)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bfocus
|
|
4
|
+
module Resources
|
|
5
|
+
# Contatos (pessoas) de um cliente — `client.customers.contacts`.
|
|
6
|
+
#
|
|
7
|
+
# Contato: `"id"`, `"external_id"`, `"name"`, `"role"`, `"email"`, `"phone"`, `"notes"`,
|
|
8
|
+
# `"is_primary"`, `"created_at"`, `"updated_at"`.
|
|
9
|
+
class CustomerContacts < Base
|
|
10
|
+
# Contatos do cliente. `GET /customers/{external_id}/contacts`
|
|
11
|
+
# @return [Array<Hash>]
|
|
12
|
+
def list(external_id, timeout: nil)
|
|
13
|
+
call("GET", "/customers/#{segment(external_id, 'external_id')}/contacts", timeout: timeout)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Cria ou atualiza um contato pelo `external_id` dele. Só o que vier muda; `nil` limpa.
|
|
17
|
+
# `PUT /customers/{external_id}/contacts/{contact_external_id}`
|
|
18
|
+
# @return [Hash] o contato.
|
|
19
|
+
def upsert(external_id, contact_external_id, name: UNSET, role: UNSET, email: UNSET,
|
|
20
|
+
phone: UNSET, notes: UNSET, is_primary: UNSET, idempotency_key: nil, timeout: nil)
|
|
21
|
+
ext = segment(external_id, "external_id")
|
|
22
|
+
cext = segment(contact_external_id, "contact_external_id")
|
|
23
|
+
body = compact(
|
|
24
|
+
"name" => name, "role" => role, "email" => email, "phone" => phone,
|
|
25
|
+
"notes" => notes, "is_primary" => is_primary
|
|
26
|
+
)
|
|
27
|
+
call("PUT", "/customers/#{ext}/contacts/#{cext}",
|
|
28
|
+
body: body, idempotency_key: idempotency_key, timeout: timeout)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Remove um contato. `DELETE /customers/{external_id}/contacts/{contact_external_id}`
|
|
32
|
+
# @return [Hash] `{"deleted" => true}`
|
|
33
|
+
def delete(external_id, contact_external_id, idempotency_key: nil, timeout: nil)
|
|
34
|
+
ext = segment(external_id, "external_id")
|
|
35
|
+
cext = segment(contact_external_id, "contact_external_id")
|
|
36
|
+
call("DELETE", "/customers/#{ext}/contacts/#{cext}",
|
|
37
|
+
idempotency_key: idempotency_key, timeout: timeout)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Produtos vinculados a um cliente — `client.customers.products`.
|
|
42
|
+
#
|
|
43
|
+
# Produto vinculado: `"id"`, `"slug"`, `"name"`, `"is_active"`.
|
|
44
|
+
class CustomerProducts < Base
|
|
45
|
+
# Produtos do cliente. `GET /customers/{external_id}/products`
|
|
46
|
+
# @return [Array<Hash>]
|
|
47
|
+
def list(external_id, timeout: nil)
|
|
48
|
+
call("GET", "/customers/#{segment(external_id, 'external_id')}/products", timeout: timeout)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Vincula um produto ao cliente (idempotente). `PUT /customers/{external_id}/products/{slug}`
|
|
52
|
+
# @return [Hash] o produto vinculado.
|
|
53
|
+
def attach(external_id, product_slug, idempotency_key: nil, timeout: nil)
|
|
54
|
+
ext = segment(external_id, "external_id")
|
|
55
|
+
slug = segment(product_slug, "product_slug")
|
|
56
|
+
call("PUT", "/customers/#{ext}/products/#{slug}",
|
|
57
|
+
idempotency_key: idempotency_key, timeout: timeout)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Desvincula um produto. `DELETE /customers/{external_id}/products/{slug}`
|
|
61
|
+
# @return [Hash] `{"deleted" => true}`
|
|
62
|
+
def detach(external_id, product_slug, idempotency_key: nil, timeout: nil)
|
|
63
|
+
ext = segment(external_id, "external_id")
|
|
64
|
+
slug = segment(product_slug, "product_slug")
|
|
65
|
+
call("DELETE", "/customers/#{ext}/products/#{slug}",
|
|
66
|
+
idempotency_key: idempotency_key, timeout: timeout)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Histórico de interações de um cliente — `client.customers.interactions`.
|
|
71
|
+
#
|
|
72
|
+
# Interação: `"id"`, `"content"`, `"is_internal"`, `"author_kind"`, `"author_name"`,
|
|
73
|
+
# `"created_at"`.
|
|
74
|
+
class CustomerInteractions < Base
|
|
75
|
+
# Uma página de interações. `GET /customers/{external_id}/interactions`
|
|
76
|
+
# @return [Bfocus::Page]
|
|
77
|
+
def list(external_id, page: nil, page_size: nil, timeout: nil)
|
|
78
|
+
ext = segment(external_id, "external_id")
|
|
79
|
+
paged("/customers/#{ext}/interactions", { "page" => page, "page_size" => page_size }, timeout)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Todas as interações, página a página (`page_size` padrão 100).
|
|
83
|
+
# @return [Enumerator::Lazy<Hash>]
|
|
84
|
+
def list_all(external_id, page_size: 100, timeout: nil)
|
|
85
|
+
segment(external_id, "external_id") # valida já, não na 1ª iteração
|
|
86
|
+
iterate { |number| list(external_id, page: number, page_size: page_size, timeout: timeout) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Registra uma interação (nota) no cliente. `POST /customers/{external_id}/interactions`
|
|
90
|
+
#
|
|
91
|
+
# @param content [String] texto/HTML da interação.
|
|
92
|
+
# @param is_internal [Boolean] nota interna (padrão da API: `true`).
|
|
93
|
+
# @param author_email [String, nil] e-mail de um usuário do bFocus para constar como autor.
|
|
94
|
+
# @return [Hash] a interação.
|
|
95
|
+
def create(external_id, content, is_internal: UNSET, author_email: UNSET,
|
|
96
|
+
idempotency_key: nil, timeout: nil)
|
|
97
|
+
ext = segment(external_id, "external_id")
|
|
98
|
+
body = compact("content" => content, "is_internal" => is_internal, "author_email" => author_email)
|
|
99
|
+
call("POST", "/customers/#{ext}/interactions",
|
|
100
|
+
body: body, idempotency_key: idempotency_key, timeout: timeout)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Clientes (empresas) — `client.customers`. Sub-recursos: {#contacts}, {#products},
|
|
105
|
+
# {#interactions}.
|
|
106
|
+
#
|
|
107
|
+
# Cliente: `"id"`, `"external_id"`, `"name"`, `"document"`, `"email"`, `"phone"`,
|
|
108
|
+
# `"website"`, `"notes"`, `"custom_fields"` (lista de `{"key", "label", "type", "value",
|
|
109
|
+
# "visibility"}`), `"is_active"`, `"created_at"`, `"updated_at"`.
|
|
110
|
+
class Customers < Base
|
|
111
|
+
# @return [CustomerContacts]
|
|
112
|
+
attr_reader :contacts
|
|
113
|
+
# @return [CustomerProducts]
|
|
114
|
+
attr_reader :products
|
|
115
|
+
# @return [CustomerInteractions]
|
|
116
|
+
attr_reader :interactions
|
|
117
|
+
|
|
118
|
+
def initialize(transport)
|
|
119
|
+
super
|
|
120
|
+
@contacts = CustomerContacts.new(transport)
|
|
121
|
+
@products = CustomerProducts.new(transport)
|
|
122
|
+
@interactions = CustomerInteractions.new(transport)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Cria ou atualiza um cliente pelo `external_id` do seu sistema. `PUT /customers/{external_id}`
|
|
126
|
+
#
|
|
127
|
+
# Só os campos informados mudam; `nil` limpa. `custom_fields` (lista de
|
|
128
|
+
# `{key:, label:, type:, value:, options:}`), quando enviado, **substitui** a lista inteira.
|
|
129
|
+
# @return [Hash] o cliente.
|
|
130
|
+
def upsert(external_id, name: UNSET, document: UNSET, email: UNSET, phone: UNSET,
|
|
131
|
+
website: UNSET, notes: UNSET, custom_fields: UNSET, idempotency_key: nil, timeout: nil)
|
|
132
|
+
ext = segment(external_id, "external_id")
|
|
133
|
+
body = compact(
|
|
134
|
+
"name" => name, "document" => document, "email" => email, "phone" => phone,
|
|
135
|
+
"website" => website, "notes" => notes, "custom_fields" => custom_fields
|
|
136
|
+
)
|
|
137
|
+
call("PUT", "/customers/#{ext}", body: body, idempotency_key: idempotency_key, timeout: timeout)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Um cliente. `GET /customers/{external_id}`
|
|
141
|
+
# @return [Hash]
|
|
142
|
+
def get(external_id, timeout: nil)
|
|
143
|
+
call("GET", "/customers/#{segment(external_id, 'external_id')}", timeout: timeout)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Uma página de clientes. `GET /customers`
|
|
147
|
+
#
|
|
148
|
+
# @param q [String, nil] busca por nome/documento/e-mail.
|
|
149
|
+
# @param updated_since [Time, DateTime, Date, String, nil] só os alterados a partir deste
|
|
150
|
+
# instante (`Time` vira ISO 8601 UTC com `Z`; string passa como veio).
|
|
151
|
+
# @return [Bfocus::Page]
|
|
152
|
+
def list(q: nil, updated_since: nil, page: nil, page_size: nil, timeout: nil)
|
|
153
|
+
query = { "q" => q, "updated_since" => updated_since, "page" => page, "page_size" => page_size }
|
|
154
|
+
paged("/customers", query, timeout)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Todos os clientes, página a página (`page_size` padrão 100). Ideal para sincronização
|
|
158
|
+
# incremental: guarde o instante da última rodada e passe em `updated_since`.
|
|
159
|
+
# @return [Enumerator::Lazy<Hash>]
|
|
160
|
+
def list_all(q: nil, updated_since: nil, page_size: 100, timeout: nil)
|
|
161
|
+
iterate do |number|
|
|
162
|
+
list(q: q, updated_since: updated_since, page: number, page_size: page_size, timeout: timeout)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Exclui um cliente. `DELETE /customers/{external_id}`
|
|
167
|
+
# @return [Hash] `{"deleted" => true}`
|
|
168
|
+
def delete(external_id, idempotency_key: nil, timeout: nil)
|
|
169
|
+
call("DELETE", "/customers/#{segment(external_id, 'external_id')}",
|
|
170
|
+
idempotency_key: idempotency_key, timeout: timeout)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bfocus
|
|
4
|
+
module Resources
|
|
5
|
+
# Artigos da base de conhecimento — `client.kb.articles`. Exige o módulo de Atendimento
|
|
6
|
+
# (sem ele: `PermissionDeniedError` com `code == "MODULE_NOT_CONTRACTED"`).
|
|
7
|
+
#
|
|
8
|
+
# Artigo (resumo — `list`, `list_all`, `batch_upsert`): `"id"`, `"external_id"`,
|
|
9
|
+
# `"product"`, `"title"`, `"excerpt"`, `"status"`, `"origin"`, `"published_at"`,
|
|
10
|
+
# `"created_at"`, `"updated_at"`. Artigo completo (`get`, `upsert`, `publish`,
|
|
11
|
+
# `unpublish`): o resumo + `"body_html"`.
|
|
12
|
+
class KBArticles < Base
|
|
13
|
+
# Limite da API por requisição de {#batch_upsert}; a SDK divide acima disso.
|
|
14
|
+
BATCH_SIZE = 100
|
|
15
|
+
|
|
16
|
+
# Uma página de artigos (resumo, sem `body_html`). `GET /kb/articles`
|
|
17
|
+
#
|
|
18
|
+
# @param product [String, nil] slug do produto.
|
|
19
|
+
# @param status [String, nil] `"draft"` ou `"published"`.
|
|
20
|
+
# @param q [String, nil] busca no título e no texto.
|
|
21
|
+
# @param updated_since [Time, DateTime, Date, String, nil]
|
|
22
|
+
# @return [Bfocus::Page]
|
|
23
|
+
def list(product: nil, status: nil, q: nil, updated_since: nil, page: nil, page_size: nil, timeout: nil)
|
|
24
|
+
query = {
|
|
25
|
+
"product" => product, "status" => status, "q" => q, "updated_since" => updated_since,
|
|
26
|
+
"page" => page, "page_size" => page_size
|
|
27
|
+
}
|
|
28
|
+
paged("/kb/articles", query, timeout)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Todos os artigos (com os filtros), página a página (`page_size` padrão 100).
|
|
32
|
+
# @return [Enumerator::Lazy<Hash>]
|
|
33
|
+
def list_all(product: nil, status: nil, q: nil, updated_since: nil, page_size: 100, timeout: nil)
|
|
34
|
+
iterate do |number|
|
|
35
|
+
list(product: product, status: status, q: q, updated_since: updated_since,
|
|
36
|
+
page: number, page_size: page_size, timeout: timeout)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Um artigo (com `body_html`). `GET /kb/articles/{external_id}`
|
|
41
|
+
# @return [Hash]
|
|
42
|
+
def get(external_id, timeout: nil)
|
|
43
|
+
call("GET", "/kb/articles/#{article_id(external_id)}", timeout: timeout)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Cria ou atualiza um artigo pelo `external_id` (sem `/`; use `:`).
|
|
47
|
+
# `PUT /kb/articles/{external_id}`. `product: nil` (explícito) torna o artigo global;
|
|
48
|
+
# `status: "published"` publica.
|
|
49
|
+
# @return [Hash] o artigo completo.
|
|
50
|
+
def upsert(external_id, title: UNSET, body_html: UNSET, body_markdown: UNSET,
|
|
51
|
+
product: UNSET, status: UNSET, idempotency_key: nil, timeout: nil)
|
|
52
|
+
body = compact(
|
|
53
|
+
"title" => title, "body_html" => body_html, "body_markdown" => body_markdown,
|
|
54
|
+
"product" => product, "status" => status
|
|
55
|
+
)
|
|
56
|
+
call("PUT", "/kb/articles/#{article_id(external_id)}",
|
|
57
|
+
body: body, idempotency_key: idempotency_key, timeout: timeout)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Cria/atualiza **qualquer quantidade** de artigos. `POST /kb/articles/batch`
|
|
61
|
+
#
|
|
62
|
+
# A SDK divide em lotes de 100 (limite da API), envia em sequência e devolve UM
|
|
63
|
+
# resultado: `"results"` na ordem enviada e contadores somados. Falha de um item não
|
|
64
|
+
# derruba os outros (`"ok" => false` + `"error"` no resultado dele). Lista vazia devolve
|
|
65
|
+
# o resultado zerado sem chamar a API.
|
|
66
|
+
#
|
|
67
|
+
# Cada item (Hash, chaves string ou símbolo) precisa de `external_id`; os demais campos
|
|
68
|
+
# seguem a regra do {#upsert} (ausente = não muda; `product: nil` = global).
|
|
69
|
+
#
|
|
70
|
+
# Se um lote falhar por inteiro (rede, 429 esgotado…), o erro sobe e os lotes anteriores
|
|
71
|
+
# já foram aplicados — rodar de novo é seguro (é upsert).
|
|
72
|
+
#
|
|
73
|
+
# @param idempotency_key [String, nil] o 1º lote usa a chave como veio; os seguintes,
|
|
74
|
+
# `"<chave>:<n>"` (n = 2, 3, …). Sem chave, cada lote gera a sua.
|
|
75
|
+
# @return [Hash] `{"results" => [{"external_id", "ok", "action", "error", "article"}, …],
|
|
76
|
+
# "created", "updated", "unchanged", "failed"}`
|
|
77
|
+
def batch_upsert(articles, idempotency_key: nil, timeout: nil)
|
|
78
|
+
if articles.is_a?(Hash) || !articles.respond_to?(:each_with_index)
|
|
79
|
+
raise TypeError, "articles precisa ser uma lista de Hash."
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
items = articles.each_with_index.map { |article, index| batch_item(article, index) }
|
|
83
|
+
outcome = { "results" => [], "created" => 0, "updated" => 0, "unchanged" => 0, "failed" => 0 }
|
|
84
|
+
user_key = idempotency_key.to_s
|
|
85
|
+
items.each_slice(BATCH_SIZE).with_index(1) do |chunk, number|
|
|
86
|
+
key = user_key.empty? ? nil : user_key
|
|
87
|
+
key = "#{key}:#{number}" if key && number > 1
|
|
88
|
+
data = call("POST", "/kb/articles/batch",
|
|
89
|
+
body: { "articles" => chunk }, idempotency_key: key, timeout: timeout)
|
|
90
|
+
merge_outcome(outcome, data)
|
|
91
|
+
end
|
|
92
|
+
outcome
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Publica um artigo. `POST /kb/articles/{external_id}/publish`
|
|
96
|
+
# @return [Hash] o artigo completo.
|
|
97
|
+
def publish(external_id, idempotency_key: nil, timeout: nil)
|
|
98
|
+
call("POST", "/kb/articles/#{article_id(external_id)}/publish",
|
|
99
|
+
idempotency_key: idempotency_key, timeout: timeout)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Volta um artigo para rascunho. `POST /kb/articles/{external_id}/unpublish`
|
|
103
|
+
# @return [Hash] o artigo completo.
|
|
104
|
+
def unpublish(external_id, idempotency_key: nil, timeout: nil)
|
|
105
|
+
call("POST", "/kb/articles/#{article_id(external_id)}/unpublish",
|
|
106
|
+
idempotency_key: idempotency_key, timeout: timeout)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Exclui um artigo. `DELETE /kb/articles/{external_id}`
|
|
110
|
+
# @return [Hash] `{"deleted" => true}`
|
|
111
|
+
def delete(external_id, idempotency_key: nil, timeout: nil)
|
|
112
|
+
call("DELETE", "/kb/articles/#{article_id(external_id)}",
|
|
113
|
+
idempotency_key: idempotency_key, timeout: timeout)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def article_id(external_id)
|
|
119
|
+
Codec.path_segment(external_id, "external_id", allow_slash: false)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def batch_item(article, index)
|
|
123
|
+
raise TypeError, "articles[#{index}] precisa ser um Hash." unless article.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
item = article.each_with_object({}) do |(key, value), out|
|
|
126
|
+
out[key.to_s] = value unless value.equal?(UNSET)
|
|
127
|
+
end
|
|
128
|
+
ext = item["external_id"]
|
|
129
|
+
unless ext.is_a?(String) && !ext.empty?
|
|
130
|
+
raise ArgumentError, "articles[#{index}]: external_id é obrigatório."
|
|
131
|
+
end
|
|
132
|
+
if ext.include?("/")
|
|
133
|
+
raise ArgumentError, "articles[#{index}]: external_id não aceita '/' — use ':' (#{ext.inspect})"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
item
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def merge_outcome(outcome, data)
|
|
140
|
+
return unless data.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
data.each do |field, value|
|
|
143
|
+
if field == "results"
|
|
144
|
+
outcome["results"].concat(value) if value.is_a?(Array)
|
|
145
|
+
elsif value.is_a?(Integer)
|
|
146
|
+
outcome[field] = outcome[field].to_i + value
|
|
147
|
+
else # campo novo não numérico: preservado (o último lote vence)
|
|
148
|
+
outcome[field] = value
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Base de conhecimento — `client.kb` (artigos em {#articles}). Exige o módulo de
|
|
155
|
+
# Atendimento.
|
|
156
|
+
class KnowledgeBase < Base
|
|
157
|
+
# @return [KBArticles]
|
|
158
|
+
attr_reader :articles
|
|
159
|
+
|
|
160
|
+
def initialize(transport)
|
|
161
|
+
super
|
|
162
|
+
@articles = KBArticles.new(transport)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Busca semântica/textual nos artigos publicados. `GET /kb/search`
|
|
166
|
+
#
|
|
167
|
+
# @param q [String] pergunta ou termos.
|
|
168
|
+
# @param product [String, nil] slug do produto para restringir.
|
|
169
|
+
# @param limit [Integer, nil] máximo de resultados (padrão da API: 5; máximo 20).
|
|
170
|
+
# @return [Array<Hash>] `{"id", "external_id", "title", "excerpt"}`
|
|
171
|
+
def search(q, product: nil, limit: nil, timeout: nil)
|
|
172
|
+
call("GET", "/kb/search", query: { "q" => q, "product" => product, "limit" => limit }, timeout: timeout)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|