bzapper 0.7.1 → 0.8.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c30f72803594b155b3d1e19c4f375b634de0c9da69ffed8487f598e27e281661
4
- data.tar.gz: e303cafe2aea6f5174c67cc11201d52213ec7dd8290cf75357402f475693533f
3
+ metadata.gz: 2492fe46b1c91f8b88b673e102de183388ec0aeba9686f83de3d62b50eb4cc01
4
+ data.tar.gz: 57c3253532c2a83d67cbbc21b811fc05f97a2c7ac331462fc7d4b60f4bb8a0c2
5
5
  SHA512:
6
- metadata.gz: 634a810f034b09f0389c1f2dc4ff95de5c13fee9c5cd1196eee55d83b644e7df03178a313a1165fde6f090556195a1ec428c8df1b18c5b8f9c7ba9439cd040c1
7
- data.tar.gz: 8045dda0d3906b6c43bc60f4750891d5e754464d80fea1fdc277e7ff253cab428a5125e255faf68bb06fe43da49b18c13617f1fe016ec835c40241dcf2e49f99
6
+ metadata.gz: 60d10efd60798233d8c40d2f006da6d322d38af941a86d06cb148230ec6cf3a9df435a70a1db49f2630319781b76199e75fac7c2d736bdaf38a2e943279bd926
7
+ data.tar.gz: cfee6f2f48d55e3a40934cb509e85ac5832b42f76c1e677dd17d8f8f0f5384f0b617cc22ba25535c3375f7591ef8f959fc90a56cf3acd523696c170fab569287
data/README.md CHANGED
@@ -10,13 +10,13 @@ Zero dependências de runtime (só biblioteca padrão: `net/http`, `json`, `open
10
10
  ## Instalação
11
11
 
12
12
  ```bash
13
- gem install bzapper -v 0.7.1
13
+ gem install bzapper -v 0.8.1
14
14
  ```
15
15
 
16
16
  Ou no `Gemfile` — **fixe a versão exata** (cada release declara se muda a superfície pública):
17
17
 
18
18
  ```ruby
19
- gem "bzapper", "0.7.1"
19
+ gem "bzapper", "0.8.1"
20
20
  ```
21
21
 
22
22
  ## Hello world
@@ -53,6 +53,27 @@ client = Bzapper::Client.new(
53
53
  Construir o cliente não faz nenhuma chamada de rede. Um cliente pode ser compartilhado entre
54
54
  threads.
55
55
 
56
+ ### Trocar a chave sem derrubar a integração (rotação)
57
+
58
+ `rotate_my_key` (admin) cria uma chave **nova** herdando papel, escopos, projeto e nome da
59
+ antiga, e mantém a antiga funcionando por um período de graça — dá tempo de fazer o deploy sem
60
+ janela de erro. A chave crua aparece **uma única vez**, no retorno. Depois do prazo a antiga
61
+ responde `401 key_expired`; `revoke_in_seconds: 0` revoga na hora (padrão 86400 s, máximo 30
62
+ dias). Chave de parceiro (bZapper Connect) roda pelo `PartnerClient#rotate_partner_connection_key`.
63
+
64
+ ```ruby
65
+ rotated = client.accounts.rotate_my_key(key_id, revoke_in_seconds: 3600) # 1 h de graça
66
+
67
+ rotated["api_key"] # a chave NOVA, crua — guarde agora, não volta
68
+ rotated["key"]["id"] # metadados da nova
69
+ rotated["previous_key"]["expires_at"] # quando a antiga para de funcionar (nil se revogada já)
70
+ rotated["old_key_expires_at"] # o mesmo instante, no topo do retorno
71
+ rotated["previous_key"]["rotated_to"] # id da chave que substituiu a antiga
72
+ ```
73
+
74
+ Uma chave já revogada ou já vencida responde `409` (`key_already_revoked` /
75
+ `key_already_expired`).
76
+
56
77
  ## Como os métodos funcionam
57
78
 
58
79
  Os métodos ficam em **recursos**, um por área da API, e se chamam como o `operationId` da
@@ -66,13 +87,13 @@ spec OpenAPI em snake_case (`sendText` → `send_text`):
66
87
  | `client.groups` | `list_groups`, `create_group`, `get_group`, `update_group`, `update_group_participants`, `group_invite_link`, `join_group`, `preview_group_invite`, `leave_group`, pedidos de entrada |
67
88
  | `client.conversations` | `list_conversations`, `conversation_history` |
68
89
  | `client.advanced` | editar/apagar/encaminhar mensagem, perfil, privacidade, arquivar/fixar/ler/silenciar chat, etiquetas, bloqueio, chamadas |
69
- | `client.contacts` | CRM (`list_contacts`, `create_contact`, `update_contact`…), tags, grupos de contatos, opt-in/out, supressões, `contacts_check` |
90
+ | `client.contacts` | CRM (`list_contacts`, `create_contact`, `update_contact`…), `import_contacts` (lote), `export_contacts` (CSV), tags, grupos de contatos, opt-in/out, supressões, `contacts_check` |
70
91
  | `client.campaigns` | `create_campaign`, `estimate_campaign`, destinatários, `dry_run_campaign`, `start/pause/resume/cancel_campaign` |
71
92
  | `client.pools` | pools de números |
72
93
  | `client.webhooks` | `create_webhook`, `update_webhook`, `test_webhook`, `list_webhook_deliveries`… |
73
94
  | `client.advisories` | avisos "atualize sua integração" |
74
95
  | `client.usage` / `client.billing` | consumo; plano, assinatura, add-ons, faturas, preços |
75
- | `client.accounts` | perfil, chaves de API, marca, projetos, usuários |
96
+ | `client.accounts` | perfil, chaves de API (`rotate_my_key`), marca, projetos, usuários |
76
97
  | `client.connect` | apps parceiros conectados à sua conta |
77
98
  | `client.system` | `get_health` |
78
99
 
@@ -88,6 +109,8 @@ Convenções (iguais em todos os métodos):
88
109
  vêm como `{"data" => [...], ...}` (a SDK não desembrulha, para não perder paginação e
89
110
  metadados). Campos novos da API aparecem sem quebrar nada. `204` → `nil`.
90
111
  * Parâmetro de caminho vazio, `"."` ou `".."` → `ArgumentError` antes de qualquer requisição.
112
+ * Uma exceção ao retorno: `client.contacts.export_contacts` devolve **`String`** (o CSV cru),
113
+ porque a rota responde `text/csv` e não JSON.
91
114
 
92
115
  ## Mensagens
93
116
 
@@ -162,6 +185,48 @@ client.contacts.contacts_check(instance_id: "…", phones: ["+5511999999999"]) #
162
185
 
163
186
  O vínculo contato ↔ projeto/número é mantido **automaticamente** pela API.
164
187
 
188
+ ### Importar em lote
189
+
190
+ Até **1000** contatos por chamada, upsert por telefone: o novo entra como
191
+ `pending_validation` (precisa de opt-in antes de campanha), o que já existe só recebe os campos
192
+ informados — valor em branco não apaga o que está lá. Linha ruim vai para `errors` e **não**
193
+ derruba o resto; contato suprimido/opt-out/bloqueado aparece em `skipped_rows` e nunca
194
+ ressuscita. Tags e grupos são criados na hora. `dry_run: true` valida tudo e não grava nada.
195
+
196
+ ```ruby
197
+ result = client.contacts.import_contacts(
198
+ contacts: [
199
+ { phone: "+5511999999999", name: "Ana", email: "ana@example.com", tags: %w[vip] },
200
+ { phone: "+5511888888888", name: "Bruno", document: "12345678900", document_type: "cpf",
201
+ address: { city: "São Paulo", state: "SP", country: "BR" }, groups: %w[clientes] }
202
+ ],
203
+ dry_run: true # ensaio: nada é gravado
204
+ )
205
+ result["created"] # => 2
206
+ result["errors"].each { |row| warn "linha #{row['index']} (#{row['phone']}): #{row['reason']}" }
207
+ ```
208
+
209
+ ### Exportar em CSV
210
+
211
+ `export_contacts` aceita os **mesmos filtros** do `list_contacts` (menos `offset`; use `limit`
212
+ para limitar as linhas) e devolve o **texto do CSV** — uma `String` UTF-8, não JSON. Colunas:
213
+ `phone,name,email,status,source,tags,groups,created_at,last_activity_at`, com tags e grupos
214
+ unidos por `;` e instantes em RFC 3339 UTC.
215
+
216
+ ```ruby
217
+ require "csv"
218
+
219
+ csv = client.contacts.export_contacts(status: "active", tags: %w[vip], has_email: true,
220
+ created_after: Time.utc(2026, 1, 1), limit: 50_000)
221
+
222
+ File.write("contatos.csv", csv) # gravar como veio
223
+ CSV.parse(csv, headers: true) { |row| puts row["phone"] } # ou percorrer linha a linha
224
+ ```
225
+
226
+ Vírgulas e aspas dentro dos campos vêm escapadas pelo servidor — não monte o CSV de novo,
227
+ entregue o texto ao parser. Para bases muito grandes, exporte em fatias com os filtros
228
+ (`created_after`/`created_before`, `limit`) em vez de puxar tudo de uma vez.
229
+
165
230
  ## Campanhas
166
231
 
167
232
  ```ruby
data/lib/bzapper/codec.rb CHANGED
@@ -99,9 +99,15 @@ module Bzapper
99
99
  end
100
100
  end
101
101
 
102
+ # Corpo cru → texto UTF-8 (bytes inválidos trocados). É o que as rotas de texto
103
+ # (`text/csv`, ex.: `exportContacts`) devolvem, sem passar por JSON.
104
+ def utf8(raw)
105
+ raw.to_s.dup.force_encoding(::Encoding::UTF_8).scrub
106
+ end
107
+
102
108
  # Texto UTF-8 (bytes inválidos trocados) → `[json_ou_nil, texto, é_json?]`.
103
109
  def decode_json(raw)
104
- text = raw.to_s.dup.force_encoding(::Encoding::UTF_8).scrub
110
+ text = utf8(raw)
105
111
  return [nil, text, false] if text.strip.empty?
106
112
 
107
113
  [JSON.parse(text), text, true]
@@ -47,6 +47,33 @@ module Bzapper
47
47
  timeout: timeout)
48
48
  end
49
49
 
50
+ # Rotate a tenant API key. Admin only. `POST /keys/{id}/rotate`
51
+ #
52
+ # Creates a NEW key inheriting the old one's role, scopes, project and name, and keeps the OLD
53
+ # key working for a grace period so a running integration does not break mid-deploy. The raw key
54
+ # is shown ONCE. After the deadline the old key answers `401 key_expired`. `revoke_in_seconds:
55
+ # 0` revokes it immediately (default 86400, max 30 days). Partner keys (bZapper Connect) rotate
56
+ # through `/partner/connections/{id}/rotate-key`.
57
+ #
58
+ # @param id [String] API key ID (UUID).
59
+ # @param revoke_in_seconds [Integer, nil] (corpo) Grace period for the OLD key. 0 revokes it
60
+ # immediately.
61
+ # @param idempotency_key [String, nil] chave de idempotência (senão a SDK gera uma).
62
+ # @param timeout [Numeric, nil] segundos por tentativa (padrão: o do cliente).
63
+ # @return [Hash, Array, nil] o JSON da resposta, inteiro (nil em 204).
64
+ # @raise [Bzapper::Error] resposta fora de 2xx ou falha de rede.
65
+ # @raise [ArgumentError] parâmetro de caminho vazio, "." ou "..".
66
+ def rotate_my_key(id, revoke_in_seconds: UNSET, idempotency_key: nil, timeout: nil)
67
+ payload = compact(
68
+ "revoke_in_seconds" => revoke_in_seconds
69
+ )
70
+ request("POST",
71
+ "/keys/#{segment(id, "id")}/rotate",
72
+ body: payload,
73
+ idempotency_key: idempotency_key,
74
+ timeout: timeout)
75
+ end
76
+
50
77
  # Authenticated identity (+ profile when it's a user session). `GET /me`
51
78
  #
52
79
  # @param timeout [Numeric, nil] segundos por tentativa (padrão: o do cliente).
@@ -27,6 +27,12 @@ module Bzapper
27
27
  idempotency_key: idempotency_key, timeout: timeout)
28
28
  end
29
29
 
30
+ # Rotas que respondem TEXTO (`text/csv`, ex.: `exportContacts`): devolve o corpo cru,
31
+ # sem JSON. Mesmos cabeçalhos, novas tentativas e erros do {#request}.
32
+ def request_text(method, path, query: nil, timeout: nil, accept: "text/csv")
33
+ @transport.request(method, path, query: query, timeout: timeout, accept: accept, as_text: true)
34
+ end
35
+
30
36
  def segment(value, name)
31
37
  Codec.path_segment(value, name)
32
38
  end
@@ -105,6 +105,33 @@ module Bzapper
105
105
  timeout: timeout)
106
106
  end
107
107
 
108
+ # Import contacts in bulk. `POST /contacts/import`
109
+ #
110
+ # Upserts up to 1000 contacts by phone in one call. A new contact is created with `source:
111
+ # import` and `status: pending_validation` (it needs opt-in before a campaign). An existing one
112
+ # has only its informed fields updated — a blank value never erases what is there. A
113
+ # suppressed/opted-out/blocked contact is reported in `skipped_rows` and never resurrected. Tags
114
+ # and groups are created on demand. A bad row is reported in `errors` and does NOT fail the rest
115
+ # of the call. `dry_run` validates everything and writes nothing.
116
+ #
117
+ # @param contacts [Array<Hash>] (corpo)
118
+ # @param dry_run [Boolean, nil] (corpo) Validates and reports without writing anything.
119
+ # @param idempotency_key [String, nil] chave de idempotência (senão a SDK gera uma).
120
+ # @param timeout [Numeric, nil] segundos por tentativa (padrão: o do cliente).
121
+ # @return [Hash, Array, nil] o JSON da resposta, inteiro (nil em 204).
122
+ # @raise [Bzapper::Error] resposta fora de 2xx ou falha de rede.
123
+ def import_contacts(contacts:, dry_run: UNSET, idempotency_key: nil, timeout: nil)
124
+ payload = compact(
125
+ "contacts" => contacts,
126
+ "dry_run" => dry_run
127
+ )
128
+ request("POST",
129
+ "/contacts/import",
130
+ body: payload,
131
+ idempotency_key: idempotency_key,
132
+ timeout: timeout)
133
+ end
134
+
108
135
  # Get a contact. `GET /contacts/{id}`
109
136
  #
110
137
  # @param id [String] Contact ID (UUID).
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ESCRITO À MÃO (não sai do script/generate.rb): `exportContacts` responde `text/csv`, e não
4
+ # JSON — está fora dos casos gerados (BRIEF §6, "CSV") e tem teste próprio
5
+ # (test/unit_test.rb, CsvExportTest). O resto do `client.contacts` é gerado
6
+ # (lib/bzapper/resources/contacts.rb).
7
+
8
+ module Bzapper
9
+ module Resources
10
+ class Contacts
11
+ # Export contacts as CSV. `GET /contacts/export`
12
+ #
13
+ # Devolve a base de contatos em **CSV** (`text/csv`, `Content-Disposition: attachment`) —
14
+ # não é JSON. Colunas:
15
+ # `phone,name,email,status,source,tags,groups,created_at,last_activity_at`; tags e grupos
16
+ # vêm unidos por `;` e os instantes em RFC 3339 UTC. Os filtros são os MESMOS do
17
+ # {#list_contacts} (só não há `offset`: use `limit` para limitar as linhas).
18
+ #
19
+ # O retorno é o texto do CSV, como veio do servidor (UTF-8) — passe-o a `CSV.parse` ou
20
+ # grave em disco. Para não carregar tudo na memória, exporte em fatias com os filtros
21
+ # (`created_after`/`created_before`, `limit`).
22
+ #
23
+ # @example Gravar em disco
24
+ # File.write("contatos.csv", client.contacts.export_contacts(status: "active"))
25
+ #
26
+ # @example Ler linha a linha
27
+ # require "csv"
28
+ # CSV.parse(client.contacts.export_contacts(tags: %w[vip]), headers: true) do |row|
29
+ # puts row["phone"]
30
+ # end
31
+ #
32
+ # @param search [String, nil] (query) Filter by name, phone, email or document.
33
+ # @param tags [Array<String>, nil] (query) Tag keys (repeat the param or a CSV list).
34
+ # @param tags_match [String, nil] (query) Match ANY (default) or ALL of `tags`.
35
+ # @param groups [Array<String>, nil] (query) Contact-group keys.
36
+ # @param project_id [String, nil] (query) Scope by project (alternative to the X-Project-Id
37
+ # header).
38
+ # @param instance_id [String, nil] (query) Filter by a number (instance) the contact
39
+ # interacted with.
40
+ # @param status [String, nil] (query)
41
+ # @param city [String, nil] (query)
42
+ # @param state [String, nil] (query)
43
+ # @param country [String, nil] (query)
44
+ # @param zip [String, nil] (query)
45
+ # @param document [String, nil] (query)
46
+ # @param has_email [Boolean, nil] (query) Only contacts that have (true) or lack (false) an
47
+ # email.
48
+ # @param last_activity_after [Time, String, nil] (query) last_message_at ≥ this instant
49
+ # (RFC3339).
50
+ # @param last_activity_before [Time, String, nil] (query) last_message_at ≤ this instant
51
+ # (RFC3339).
52
+ # @param created_after [Time, String, nil] (query) created_at ≥ this instant (RFC3339).
53
+ # @param created_before [Time, String, nil] (query) created_at ≤ this instant (RFC3339).
54
+ # @param sort [String, nil] (query)
55
+ # @param limit [Integer, nil] (query) Cap of exported rows.
56
+ # @param timeout [Numeric, nil] segundos por tentativa (padrão: o do cliente).
57
+ # @return [String] o CSV cru (UTF-8), com o cabeçalho na primeira linha.
58
+ # @raise [Bzapper::Error] resposta fora de 2xx ou falha de rede.
59
+ def export_contacts(search: nil, tags: nil, tags_match: nil, groups: nil, project_id: nil,
60
+ instance_id: nil, status: nil, city: nil, state: nil, country: nil,
61
+ zip: nil, document: nil, has_email: nil, last_activity_after: nil,
62
+ last_activity_before: nil, created_after: nil, created_before: nil,
63
+ sort: nil, limit: nil, timeout: nil)
64
+ query = {
65
+ "search" => search,
66
+ "tags" => tags,
67
+ "tags_match" => tags_match,
68
+ "groups" => groups,
69
+ "project_id" => project_id,
70
+ "instance_id" => instance_id,
71
+ "status" => status,
72
+ "city" => city,
73
+ "state" => state,
74
+ "country" => country,
75
+ "zip" => zip,
76
+ "document" => document,
77
+ "has_email" => has_email,
78
+ "last_activity_after" => last_activity_after,
79
+ "last_activity_before" => last_activity_before,
80
+ "created_after" => created_after,
81
+ "created_before" => created_before,
82
+ "sort" => sort,
83
+ "limit" => limit
84
+ }
85
+ request_text("GET", "/contacts/export", query: query, timeout: timeout)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -96,8 +96,12 @@ module Bzapper
96
96
  # Executa a chamada e devolve o JSON decodificado da resposta (`nil` sem corpo).
97
97
  #
98
98
  # `body: nil` significa SEM corpo JSON; `multipart:` é um {Upload}.
99
+ #
100
+ # `accept:` troca o `Accept` (padrão `application/json`) e `as_text: true` devolve o corpo
101
+ # CRU em texto, sem passar por JSON — é o caminho das rotas `text/csv` (`exportContacts`).
99
102
  # @raise [Bzapper::Error]
100
- def request(method, path, query: nil, body: nil, multipart: nil, idempotency_key: nil, timeout: nil)
103
+ def request(method, path, query: nil, body: nil, multipart: nil, idempotency_key: nil, timeout: nil,
104
+ accept: nil, as_text: false)
101
105
  method = method.to_s.upcase
102
106
  per_try = timeout.nil? ? @timeout : timeout
103
107
  unless per_try.is_a?(Numeric) && per_try.positive?
@@ -108,7 +112,7 @@ module Bzapper
108
112
  request_id = SecureRandom.uuid.delete("-")
109
113
  headers = {
110
114
  "Authorization" => "Bearer #{@api_key}",
111
- "Accept" => "application/json",
115
+ "Accept" => accept || "application/json",
112
116
  "X-Bzapper-Client" => CLIENT_ID,
113
117
  "User-Agent" => CLIENT_ID,
114
118
  # Mesmo id em todas as tentativas desta chamada: é como o suporte correlaciona.
@@ -146,7 +150,10 @@ module Bzapper
146
150
  )
147
151
  end
148
152
 
149
- return decode(status, resp_headers, raw, request_id) if status.between?(200, 299)
153
+ if status.between?(200, 299)
154
+ # Texto: o corpo volta como veio (o `INVALID_RESPONSE` do §4 não vale aqui).
155
+ return as_text ? Codec.utf8(raw) : decode(status, resp_headers, raw, request_id)
156
+ end
150
157
 
151
158
  if RETRY_STATUSES.include?(status) && attempt < @max_retries
152
159
  @sleeper.call(retry_delay(attempt, resp_headers["retry-after"]))
@@ -3,5 +3,5 @@
3
3
  module Bzapper
4
4
  # Versão da gem. O `scripts/release-sdks.sh` do monorepo bumpa a linha abaixo por regex
5
5
  # (ancorada no início da linha) e o `bzapper.gemspec` lê daqui — não mude o formato dela.
6
- VERSION = "0.7.1"
6
+ VERSION = "0.8.1"
7
7
  end
data/lib/bzapper.rb CHANGED
@@ -26,5 +26,7 @@ require_relative "bzapper/codec"
26
26
  require_relative "bzapper/upload"
27
27
  require_relative "bzapper/transport"
28
28
  require_relative "bzapper/resources"
29
+ # Escrito à mão: `exportContacts` responde CSV, fora dos métodos gerados.
30
+ require_relative "bzapper/resources/contacts_export"
29
31
  require_relative "bzapper/client"
30
32
  require_relative "bzapper/webhook"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bzapper
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Berni Software
@@ -31,6 +31,7 @@ files:
31
31
  - lib/bzapper/resources/campaigns.rb
32
32
  - lib/bzapper/resources/connect.rb
33
33
  - lib/bzapper/resources/contacts.rb
34
+ - lib/bzapper/resources/contacts_export.rb
34
35
  - lib/bzapper/resources/conversations.rb
35
36
  - lib/bzapper/resources/groups.rb
36
37
  - lib/bzapper/resources/instances.rb