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