bzapper 0.7.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5c424228e334385c5d1e747d6951f6c76df26255765678d7748430642cfcb4c1
4
+ data.tar.gz: 3b27b1c1a0bffffa53937fe6d0f19e8486afa18ecdbe8dd08044160103413ae5
5
+ SHA512:
6
+ metadata.gz: c071f315d70aefbaf8eb98933d36c42c2f45d2fee7b3cbdd66db0d7a141b6e978e7d65f7337d6ef2b5cdb744569fd47efe3c91cba05b8525eabf9f9234908025
7
+ data.tar.gz: b7e9fed41f47242ba1c0fd659c5dc0255ae3ac48a6db969be4ed38806c047ebb72b4f63800c5224f1a389d2b9e32efd374e73f9341399c0326f0c68c088c35b0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Berni Software
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,323 @@
1
+ # bzapper
2
+
3
+ SDK oficial em **Ruby** da API do [bZapper](https://bzapper.com.br) (WhatsApp): mensagens de
4
+ todos os tipos (e OTP), números e QR, grupos, conversas, contatos, campanhas, webhooks com
5
+ verificação de assinatura e o **bZapper Connect**.
6
+
7
+ Zero dependências de runtime (só biblioteca padrão: `net/http`, `json`, `openssl`,
8
+ `securerandom`) · Ruby 3.0+ · novas tentativas e idempotência automáticas.
9
+
10
+ ## Instalação
11
+
12
+ ```bash
13
+ gem install bzapper -v 0.7.0
14
+ ```
15
+
16
+ Ou no `Gemfile` — **fixe a versão exata** (cada release declara se muda a superfície pública):
17
+
18
+ ```ruby
19
+ gem "bzapper", "0.7.0"
20
+ ```
21
+
22
+ ## Hello world
23
+
24
+ ```ruby
25
+ require "bzapper"
26
+
27
+ client = Bzapper::Client.new(ENV.fetch("BZAPPER_API_KEY"))
28
+
29
+ sent = client.messages.send_text(to: "+5511999999999", body: "Olá do bZapper!")
30
+ puts sent["message_id"], sent["status"] # => "queued"
31
+ ```
32
+
33
+ Só `to` é obrigatório além do conteúdo: sem `instance_id`, o bZapper escolhe o número do seu
34
+ pool (rotação + afinidade de conversa). `to` é um telefone E.164 (`+5511999999999`) ou um JID.
35
+
36
+ ## Autenticação
37
+
38
+ Crie a chave no painel do bZapper em **Chaves de API** (`bz_live_...`). Ela vai em
39
+ `Authorization: Bearer <chave>` em toda requisição — a SDK cuida disso. A chave já carrega o
40
+ projeto dela; numa chave de conta, escolha o projeto com `project_id:`.
41
+
42
+ ```ruby
43
+ client = Bzapper::Client.new(
44
+ "bz_live_...",
45
+ base_url: "https://api.bzapper.com.br", # padrão; em dev: "http://localhost:8080"
46
+ timeout: 30, # segundos, por tentativa
47
+ max_retries: 2, # novas tentativas além da primeira (0 desliga)
48
+ locale: "pt-BR", # Accept-Language: mensagens de erro traduzidas
49
+ project_id: "…" # X-Project-Id: escopo de projeto
50
+ )
51
+ ```
52
+
53
+ Construir o cliente não faz nenhuma chamada de rede. Um cliente pode ser compartilhado entre
54
+ threads.
55
+
56
+ ## Como os métodos funcionam
57
+
58
+ Os métodos ficam em **recursos**, um por área da API, e se chamam como o `operationId` da
59
+ spec OpenAPI em snake_case (`sendText` → `send_text`):
60
+
61
+ | Recurso | O que tem |
62
+ | --- | --- |
63
+ | `client.messages` | `send_text`, `send_image`, `send_video`, `send_document`, `send_audio`, `send_sticker`, `send_location`, `send_contact`, `send_poll`, `send_reaction`, `send_buttons`, `send_list`, `send_otp`, `mark_read`, `presence_chat` |
64
+ | `client.scheduling` | `list_scheduled`, `cancel_scheduled` |
65
+ | `client.instances` | números: `list_instances`, `create_instance`, `connect_instance` (QR/código), `get_instance`, `disconnect_instance`, `logout_instance`, `clear_instance_session`, `archive_instance`, proxy, filtros de entrada, conta oficial… |
66
+ | `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
+ | `client.conversations` | `list_conversations`, `conversation_history` |
68
+ | `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` |
70
+ | `client.campaigns` | `create_campaign`, `estimate_campaign`, destinatários, `dry_run_campaign`, `start/pause/resume/cancel_campaign` |
71
+ | `client.pools` | pools de números |
72
+ | `client.webhooks` | `create_webhook`, `update_webhook`, `test_webhook`, `list_webhook_deliveries`… |
73
+ | `client.advisories` | avisos "atualize sua integração" |
74
+ | `client.usage` / `client.billing` | consumo; plano, assinatura, add-ons, faturas, preços |
75
+ | `client.accounts` | perfil, chaves de API, marca, projetos, usuários |
76
+ | `client.connect` | apps parceiros conectados à sua conta |
77
+ | `client.system` | `get_health` |
78
+
79
+ Convenções (iguais em todos os métodos):
80
+
81
+ * Parâmetros de **caminho** são posicionais (`client.instances.get_instance(id)`); **query** e
82
+ **corpo** são keyword args com os nomes da API (`to:`, `instance_id:`…).
83
+ * Campo de corpo que você não passa **não é enviado**. `nil` explícito vai como `null` (num
84
+ PATCH, limpa o campo): `client.contacts.update_contact(id, email: nil)`.
85
+ * Filtros de query com `nil` são omitidos; listas vão como CSV; `Time` vira ISO 8601 UTC (`Z`).
86
+ * Toda chamada aceita `timeout:`; as escritas aceitam `idempotency_key:`.
87
+ * O retorno é o **JSON inteiro** da resposta — `Hash`/`Array` com chaves **string**. Listas
88
+ vêm como `{"data" => [...], ...}` (a SDK não desembrulha, para não perder paginação e
89
+ metadados). Campos novos da API aparecem sem quebrar nada. `204` → `nil`.
90
+ * Parâmetro de caminho vazio, `"."` ou `".."` → `ArgumentError` antes de qualquer requisição.
91
+
92
+ ## Mensagens
93
+
94
+ ```ruby
95
+ to = "+5511999999999"
96
+ m = client.messages
97
+
98
+ m.send_text(to: to, body: "Pedido #42 confirmado", idempotency_key: "pedido-42")
99
+ m.send_image(to: to, media: { url: "https://picsum.photos/600", caption: "Oi" })
100
+ m.send_video(to: to, media: { url: "https://example.com/clip.mp4" })
101
+ m.send_document(to: to, media: { url: "https://example.com/nota.pdf", filename: "nota.pdf" })
102
+ m.send_audio(to: to, media: { url: "https://example.com/audio.ogg", ptt: true }) # ptt = mensagem de voz
103
+ m.send_sticker(to: to, media: { url: "https://example.com/sticker.webp" })
104
+ m.send_location(to: to, latitude: -23.5613, longitude: -46.6565, name: "Av. Paulista")
105
+ m.send_contact(to: to, contact_name: "Berni Software", contact_vcard: "BEGIN:VCARD…")
106
+ m.send_poll(to: to, name: "Pizza ou sushi?", options: %w[Pizza Sushi], selectable_count: 1)
107
+ m.send_reaction(to: to, quoted_message_id: "ABCD1234", emoji: "👍") # emoji "" remove
108
+ m.send_buttons(to: to, body: "Escolha:", buttons: [{ id: "a", title: "Opção A" }, { id: "b", title: "Opção B" }])
109
+ m.send_list(to: to, body: "Cardápio:", button_text: "Abrir",
110
+ sections: [{ title: "Bebidas", rows: [{ id: "1", title: "Café" }, { id: "2", title: "Chá" }] }])
111
+
112
+ # OTP: o código vai sozinho numa bolha (copiável); o texto de contexto é gerado se omitido.
113
+ m.send_otp(to: to, code: "482913", expiry_minutes: 5)
114
+
115
+ # Agendar: qualquer envio aceita scheduled_at (Time ou RFC 3339).
116
+ m.send_text(to: to, body: "Lembrete", scheduled_at: Time.now + 3600)
117
+ client.scheduling.list_scheduled
118
+
119
+ # Presença ("digitando…") e confirmação de leitura
120
+ m.presence_chat(instance_id: "…", to: to, state: "typing") # typing, recording, paused
121
+ m.mark_read("wa_message_id", instance_id: "…", chat: "5511999999999@s.whatsapp.net")
122
+ ```
123
+
124
+ **MediaInput** (`media:`): `{ url:, base64:, caption:, filename:, mimetype:, ptt: }` — use
125
+ `url` **ou** `base64`, nunca os dois.
126
+
127
+ **Botões e listas** não são confiáveis no WhatsApp (pior em grupos): a API **sempre** manda
128
+ também um **menu de texto numerado** equivalente. Desenhe o fluxo para funcionar só com ele.
129
+
130
+ ## Números (instâncias) e QR
131
+
132
+ ```ruby
133
+ inst = client.instances.create_instance(phone: "+5511999999999", nickname: "Vendas")
134
+ qr = client.instances.connect_instance(inst["id"], method: "qr") # ou method: "code" (código de pareamento)
135
+ puts qr["qr_code"] || qr["pair_code"]
136
+
137
+ client.instances.get_instance(inst["id"])["status"] # => "connected"
138
+ client.instances.list_instances["data"].each { |i| puts "#{i['phone']} #{i['status']}" }
139
+ ```
140
+
141
+ ## Grupos e conversas
142
+
143
+ ```ruby
144
+ g = client.groups.create_group(instance_id: "…", name: "Clientes VIP", participants: ["+5511988887777"])
145
+ client.groups.update_group_participants(g["jid"], instance_id: "…", action: "add", participants: ["+5511977776666"])
146
+ client.groups.group_invite_link(g["jid"], instance_id: "…")
147
+
148
+ client.conversations.list_conversations(instance_id: "…")
149
+ client.conversations.conversation_history("5511999999999@s.whatsapp.net", instance_id: "…", limit: 50)
150
+ client.advanced.archive_chat("5511999999999@s.whatsapp.net", instance_id: "…", on: true)
151
+ ```
152
+
153
+ ## Contatos
154
+
155
+ ```ruby
156
+ c = client.contacts.create_contact(phone: "+5511999999999", name: "Ana", email: "ana@example.com")
157
+ client.contacts.mutate_contact_tags(c["id"], add: ["vip"])
158
+ client.contacts.list_contacts(tags: %w[vip], has_email: true, limit: 100)
159
+ client.contacts.opt_out_contact(c["id"])
160
+ client.contacts.contacts_check(instance_id: "…", phones: ["+5511999999999"]) # está no WhatsApp?
161
+ ```
162
+
163
+ O vínculo contato ↔ projeto/número é mantido **automaticamente** pela API.
164
+
165
+ ## Campanhas
166
+
167
+ ```ruby
168
+ camp = client.campaigns.create_campaign(
169
+ name: "Black Friday",
170
+ variations: [{ body: "Oi {{name}}, 30% hoje!", weight: 1 }],
171
+ pacing_profile: "conservative"
172
+ )
173
+ client.campaigns.add_campaign_recipients(camp["id"], contact_filter: { tags: ["vip"] })
174
+ client.campaigns.dry_run_campaign(camp["id"])
175
+ client.campaigns.start_campaign(camp["id"])
176
+ ```
177
+
178
+ ## Webhooks
179
+
180
+ ```ruby
181
+ hook = client.webhooks.create_webhook(url: "https://seu.app/bzapper", event_types: ["message.received"])
182
+ secret = hook["secret"] # mostrado uma vez — guarde
183
+ ```
184
+
185
+ No seu endpoint, verifique a assinatura (`X-Bzapper-Signature: sha256=<hex>`, HMAC-SHA256 do
186
+ **corpo cru**, comparação em tempo constante) antes de processar:
187
+
188
+ ```ruby
189
+ # Rack / Rails
190
+ raw = request.body.read
191
+ signature = request.get_header("HTTP_X_BZAPPER_SIGNATURE")
192
+
193
+ Bzapper::Webhook.verify(secret, raw, signature) # => true / false
194
+
195
+ event = Bzapper::Webhook.construct_event(secret, raw, signature) # SignatureError se não confere
196
+ event.id # estável: use para ignorar reentregas
197
+ event.type # "message.received"
198
+ event.payload # dados do evento
199
+
200
+ # Ou com roteamento por tipo:
201
+ router = Bzapper::Webhook::Router.new(secret)
202
+ router.on("message.received") { |ev| puts ev.sender&.dig("name"), ev.payload["body"] }
203
+ router.on("instance.banned") { |ev| alertar(ev.instance_id) }
204
+ router.handle(raw, signature)
205
+ ```
206
+
207
+ ## bZapper Connect (parceiros)
208
+
209
+ Seu software deixa os clientes **dele** assinarem o bZapper Pro e conectarem o WhatsApp sem
210
+ sair do seu produto. Use o `Bzapper::PartnerClient` com a chave de **parceiro** — só no
211
+ backend, nunca no navegador:
212
+
213
+ ```ruby
214
+ partner = Bzapper::PartnerClient.new(ENV.fetch("BZAPPER_PARTNER_KEY"))
215
+
216
+ # 1) backend: cria a sessão e entrega o token ao front
217
+ session = partner.create_connect_session(
218
+ external_id: "cliente-42",
219
+ customer: { name: "Ana Souza", email: "ana@boxy.com", phone: "+5511988887777" }
220
+ )
221
+ # 2) front: BzapperConnect.open({ session: session["session_token"] }) → emite um `code`
222
+ # 3) backend: troca o code pela chave do cliente
223
+ conn = partner.exchange_connect_code(code: params[:code])
224
+ cliente = Bzapper::Client.new(conn["api_key"])
225
+ cliente.messages.send_text(to: "+5511999999999", body: "Conectado!")
226
+
227
+ partner.list_partner_connections(status: "active")
228
+ partner.rotate_partner_connection_key(conn["id"])
229
+ partner.revoke_partner_connection(conn["id"])
230
+ ```
231
+
232
+ Conectado, a chave do cliente responde **402 `connect_suspended`** enquanto o Pro dele estiver
233
+ em aberto e **401 `connect_revoked`** depois que a conexão termina. O ciclo de vida chega ao
234
+ webhook do parceiro como `connect.completed`, `connect.suspended`, `connect.resumed` e
235
+ `connect.revoked` (mesma verificação de assinatura).
236
+
237
+ Do lado do cliente, `client.connect.list_connected_apps` / `revoke_connected_app(id)` mostram e
238
+ cortam os apps parceiros conectados.
239
+
240
+ ## Erros
241
+
242
+ Toda resposta fora de 2xx vira um `Bzapper::Error` (alias `Bzapper::BzapperError`) — ou a
243
+ subclasse do status. **Use `code` na sua lógica**: é estável. `message` é texto para humanos,
244
+ traduzido conforme `locale:`, e pode mudar.
245
+
246
+ ```ruby
247
+ begin
248
+ client.messages.send_text(to: "+5511999999999", body: "oi")
249
+ rescue Bzapper::RateLimitError => e
250
+ sleep(e.retry_after || 1) # segundos do Retry-After
251
+ rescue Bzapper::PermissionDeniedError => e
252
+ warn "falta o escopo #{e.required_scope}" if e.code == "insufficient_scope"
253
+ rescue Bzapper::Error => e
254
+ warn "#{e.code} (HTTP #{e.status}) request_id=#{e.request_id}: #{e.message}"
255
+ end
256
+ ```
257
+
258
+ | Classe | Quando |
259
+ | --- | --- |
260
+ | `Bzapper::AuthenticationError` | 401 |
261
+ | `Bzapper::PermissionDeniedError` | 403 (`required_scope` = header `X-Required-Scope`) |
262
+ | `Bzapper::NotFoundError` | 404 |
263
+ | `Bzapper::ConflictError` | 409 |
264
+ | `Bzapper::ValidationError` | 400 e 422 |
265
+ | `Bzapper::RateLimitError` | 429 (`retry_after` em segundos) |
266
+ | `Bzapper::ServerError` | 5xx |
267
+ | `Bzapper::NetworkError` | conexão/timeout (`status == 0`, `code == "NETWORK_ERROR"`) |
268
+ | `Bzapper::Error` | qualquer outro status; `INVALID_RESPONSE` se um 2xx chega com corpo que não é JSON |
269
+
270
+ Campos: `code` (`body.code` → `body.error` → `HTTP_<status>`), `message`, `status`,
271
+ `request_id` (header `X-Request-Id` da resposta, senão o que a SDK enviou — **informe ao
272
+ suporte**), `retry_after`, `required_scope`, `locale` e `body` (o corpo decodificado).
273
+ `e.detailed_message` junta tudo numa linha.
274
+
275
+ Argumento inválido no seu código (chave vazia, parâmetro de caminho vazio) **não** é
276
+ `Bzapper::Error`: é `ArgumentError`/`TypeError`, na hora, sem requisição.
277
+
278
+ ## Novas tentativas e idempotência
279
+
280
+ A SDK tenta de novo (até `max_retries`, padrão 2) em erro de rede/timeout, `429`, `502`, `503`
281
+ e `504` — nada mais (um `500` ou `4xx` volta na hora). Espera o `Retry-After` quando houver
282
+ (teto 60 s), senão `min(8, 0.5 × 2^tentativa)` s + até 25% de jitter.
283
+
284
+ Cada chamada gera um `X-Request-Id` e, nas escritas (POST/PUT/PATCH/DELETE), uma
285
+ `Idempotency-Key` — **as mesmas em todas as tentativas**. A API guarda a resposta por 24 h e,
286
+ numa repetição, devolve a original (`Idempotent-Replayed: true`) em vez de executar de novo:
287
+ é o que torna a nova tentativa segura (a mensagem não sai duas vezes).
288
+
289
+ Para amarrar a idempotência a algo do **seu** sistema (ex.: o id do pedido), passe a sua chave:
290
+
291
+ ```ruby
292
+ client.messages.send_text(to: "+5511999999999", body: "Pedido #42 confirmado", idempotency_key: "pedido-42")
293
+ ```
294
+
295
+ ## Versões
296
+
297
+ A gem segue a versão das SDKs do bZapper (todas as linguagens saem na mesma versão). Cada
298
+ release declara se muda a superfície pública (assinatura) ou se é só aditiva — por isso fixe a
299
+ versão exata no `Gemfile` e atualize de propósito. `Bzapper::VERSION` vai no header
300
+ `X-Bzapper-Client` (`bzapper-ruby/<versão>`): é por ele que o bZapper avisa **só** quem roda
301
+ uma versão afetada por uma correção.
302
+
303
+ ## Exemplo
304
+
305
+ [`examples/quickstart.rb`](examples/quickstart.rb):
306
+
307
+ ```bash
308
+ BZAPPER_API_KEY=bz_live_... BZAPPER_TO=+5511999999999 ruby examples/quickstart.rb
309
+ ```
310
+
311
+ ## Desenvolvimento
312
+
313
+ ```bash
314
+ bundle install
315
+ bundle exec rake test # conformidade (test/fixtures/conformance/cases.json) + unitários + versão
316
+ ```
317
+
318
+ Os métodos em `lib/bzapper/resources/` são gerados da spec (`ruby script/generate.rb`, só no
319
+ monorepo); a suíte falha se algum endpoint da spec ficar sem método.
320
+
321
+ ## Licença
322
+
323
+ MIT — Berni Software.
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bzapper
4
+ # Validação e montagem do transporte, comum ao {Client} e ao {PartnerClient}.
5
+ # @api private
6
+ module ClientOptions
7
+ private
8
+
9
+ def build_transport(api_key, base_url:, timeout:, max_retries:, locale:, project_id:, sleeper:)
10
+ label = self.class.name
11
+ raise TypeError, "#{label}: api_key precisa ser String (ex.: \"bz_live_...\")." unless api_key.is_a?(String)
12
+ raise ArgumentError, "#{label}: api_key é obrigatória (ex.: \"bz_live_...\")." if api_key.strip.empty?
13
+ unless max_retries.is_a?(Integer) && max_retries >= 0
14
+ raise ArgumentError, "#{label}: max_retries precisa ser um inteiro >= 0."
15
+ end
16
+ unless timeout.is_a?(Numeric) && timeout.positive?
17
+ raise ArgumentError, "#{label}: timeout precisa ser > 0 (segundos)."
18
+ end
19
+ if !sleeper.nil? && !sleeper.respond_to?(:call)
20
+ raise ArgumentError, "#{label}: sleeper precisa responder a #call(segundos)."
21
+ end
22
+
23
+ url = base_url.to_s.strip
24
+ url = DEFAULT_BASE_URL if url.empty?
25
+ @masked_key = api_key.length > 8 ? "#{api_key[0, 8]}..." : "..."
26
+ Transport.new(api_key, base_url: url, timeout: timeout, max_retries: max_retries,
27
+ locale: locale, project_id: project_id, sleeper: sleeper)
28
+ end
29
+
30
+ public
31
+
32
+ # @return [String] URL da API (sem barra final).
33
+ def base_url
34
+ @transport.base_url
35
+ end
36
+
37
+ # @return [Numeric] segundos por tentativa.
38
+ def timeout
39
+ @transport.timeout
40
+ end
41
+
42
+ # @return [Integer] novas tentativas além da primeira.
43
+ def max_retries
44
+ @transport.max_retries
45
+ end
46
+
47
+ def inspect
48
+ "#<#{self.class.name} api_key=#{@masked_key.inspect} base_url=#{base_url.inspect}>"
49
+ end
50
+ alias to_s inspect
51
+ end
52
+
53
+ # Cliente da API do bZapper.
54
+ #
55
+ # Nada é chamado na rede ao construir. Pode ser compartilhado entre threads (cada tentativa
56
+ # abre a própria conexão). Os métodos ficam nos recursos (um por tag da spec):
57
+ # `messages`, `instances`, `groups`, `conversations`, `advanced`, `contacts`, `campaigns`,
58
+ # `scheduling`, `pools`, `webhooks`, `advisories`, `usage`, `billing`, `accounts`, `connect`
59
+ # e `system`.
60
+ #
61
+ # @example
62
+ # client = Bzapper::Client.new(ENV.fetch("BZAPPER_API_KEY"))
63
+ # client.messages.send_text(to: "5511999990000", body: "Olá!")
64
+ class Client
65
+ include ClientOptions
66
+ include ResourceAccessors
67
+
68
+ # @param api_key [String] chave de API (`bz_live_...`, painel → Chaves de API). Único
69
+ # argumento posicional e obrigatório.
70
+ # @param base_url [String] URL da API, sem barra final. Padrão: produção
71
+ # (`https://api.bzapper.com.br`). Em dev: `http://localhost:8080`.
72
+ # @param timeout [Numeric] segundos por tentativa (padrão 30).
73
+ # @param max_retries [Integer] novas tentativas além da primeira em erro de rede/timeout,
74
+ # 429, 502, 503 e 504 (padrão 2; `0` desliga).
75
+ # @param locale [String, nil] enviado como `Accept-Language` (mensagens de erro traduzidas).
76
+ # @param project_id [String, nil] enviado como `X-Project-Id` (escopo de projeto; a chave já
77
+ # traz o dela).
78
+ # @param sleeper [#call, nil] espera entre tentativas, chamada com os segundos (padrão
79
+ # `Kernel#sleep`). Serve para testes não dormirem de verdade.
80
+ # @raise [ArgumentError] chave vazia ou opção inválida.
81
+ # @raise [TypeError] chave que não é String.
82
+ def initialize(api_key, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
83
+ max_retries: DEFAULT_MAX_RETRIES, locale: nil, project_id: nil, sleeper: nil)
84
+ @transport = build_transport(api_key, base_url: base_url, timeout: timeout,
85
+ max_retries: max_retries, locale: locale,
86
+ project_id: project_id, sleeper: sleeper)
87
+ build_resources(@transport)
88
+ end
89
+ end
90
+
91
+ # Cliente do **bZapper Connect** do lado do PARCEIRO (`/partner/*`): o software parceiro
92
+ # deixa os clientes DELE assinarem o bZapper Pro e conectarem o WhatsApp sem sair do produto.
93
+ #
94
+ # Autentica com a chave de parceiro — guarde-a só no **backend**, nunca no navegador. Mesmas
95
+ # opções, cabeçalhos, erros e novas tentativas do {Client}; os métodos (`get_partner_me`,
96
+ # `create_connect_session`, `exchange_connect_code`, `list_partner_connections`,
97
+ # `get_partner_connection`, `revoke_partner_connection`, `rotate_partner_connection_key`)
98
+ # ficam direto nele.
99
+ #
100
+ # @example
101
+ # partner = Bzapper::PartnerClient.new(ENV.fetch("BZAPPER_PARTNER_KEY"))
102
+ # session = partner.create_connect_session(external_id: "cliente-42",
103
+ # customer: { name: "Ana", email: "ana@example.com" })
104
+ # # front: BzapperConnect.open({ session }) → `code`; backend:
105
+ # key = partner.exchange_connect_code(code: code)["api_key"]
106
+ class PartnerClient
107
+ include ClientOptions
108
+ include Resources::Requests
109
+ include Resources::PartnerOperations
110
+
111
+ # Todos os status de uma conexão do Connect.
112
+ CONNECTION_STATUSES = %w[pending_account pending_payment pending_number active suspended revoked].freeze
113
+
114
+ # Mesmas opções do {Client#initialize}.
115
+ def initialize(api_key, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
116
+ max_retries: DEFAULT_MAX_RETRIES, locale: nil, project_id: nil, sleeper: nil)
117
+ @transport = build_transport(api_key, base_url: base_url, timeout: timeout,
118
+ max_retries: max_retries, locale: locale,
119
+ project_id: project_id, sleeper: sleeper)
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bzapper
4
+ # Codificação de caminho, query e corpo. Sem estado; nada aqui faz rede.
5
+ # @api private
6
+ module Codec
7
+ module_function
8
+
9
+ UNRESERVED = /[^A-Za-z0-9\-._~]/n.freeze
10
+
11
+ # Percent-encode de um valor inteiro (tudo fora de `A-Z a-z 0-9 - . _ ~`), em UTF-8.
12
+ def escape(value)
13
+ text = value.to_s
14
+ text = text.encode(::Encoding::UTF_8) unless text.encoding == ::Encoding::BINARY
15
+ text.b.gsub(UNRESERVED) { |byte| format("%%%02X", byte.ord) }.force_encoding(::Encoding::US_ASCII)
16
+ end
17
+
18
+ # Percent-encode de UM segmento de caminho (`abc 1` → `abc%201`, `/` → `%2F`). JIDs
19
+ # (`5511999990000@s.whatsapp.net`) passam como vieram, só codificados.
20
+ #
21
+ # Vazio, `"."` e `".."` são recusados antes de qualquer requisição: o cliente HTTP (ou um
22
+ # proxy) resolveria `%2E%2E` como navegação de caminho e chamaria outra rota.
23
+ # @raise [ArgumentError]
24
+ def path_segment(value, name)
25
+ raise ArgumentError, "#{name} é obrigatório." if value.nil? || value.equal?(UNSET)
26
+
27
+ text = value.to_s
28
+ raise ArgumentError, "#{name} não pode ser vazio." if text.empty?
29
+ raise ArgumentError, "#{name} não pode ser #{text.inspect}." if [".", ".."].include?(text)
30
+
31
+ escape(text)
32
+ end
33
+
34
+ # `Time`/`DateTime` → ISO 8601 em UTC com `Z` (microssegundos só quando houver).
35
+ def iso_utc(time)
36
+ time = time.getutc
37
+ text = time.strftime("%Y-%m-%dT%H:%M:%S")
38
+ text += format(".%06d", time.usec) unless time.usec.zero?
39
+ "#{text}Z"
40
+ end
41
+
42
+ # Valor de query: booleanos como `true`/`false`, datas em ISO 8601 UTC com `Z`, listas
43
+ # como CSV (`style: form, explode: false` da spec), string passa como veio.
44
+ def query_value(value)
45
+ case value
46
+ when Array then value.map { |item| query_value(item) }.join(",")
47
+ when true then "true"
48
+ when false then "false"
49
+ when Time then iso_utc(value)
50
+ else
51
+ if defined?(::DateTime) && value.is_a?(::DateTime)
52
+ iso_utc(value.to_time)
53
+ elsif defined?(::Date) && value.is_a?(::Date)
54
+ "#{value.strftime('%Y-%m-%d')}T00:00:00Z"
55
+ else
56
+ value.to_s
57
+ end
58
+ end
59
+ end
60
+
61
+ # `?a=1&b=2` com os pares informados (`nil` é omitido); `""` se não sobra nenhum.
62
+ def query_string(query)
63
+ return "" if query.nil? || query.empty?
64
+
65
+ pairs = query.each_with_object([]) do |(key, value), acc|
66
+ next if value.nil? || value.equal?(UNSET)
67
+
68
+ acc << "#{escape(key)}=#{escape(query_value(value))}"
69
+ end
70
+ pairs.empty? ? "" : "?#{pairs.join('&')}"
71
+ end
72
+
73
+ # Corpo só com o que o usuário informou: {UNSET} sai; `nil` fica e vira `null`.
74
+ def compact(fields)
75
+ fields.reject { |_key, value| value.equal?(UNSET) }
76
+ end
77
+
78
+ # Converte o corpo para tipos JSON: chaves viram string, `UNSET` em Hash some, datas viram
79
+ # ISO 8601 (instantes em UTC com `Z`).
80
+ def jsonable(value)
81
+ case value
82
+ when Hash
83
+ value.each_with_object({}) do |(key, item), out|
84
+ next if item.equal?(UNSET)
85
+
86
+ out[key.to_s] = jsonable(item)
87
+ end
88
+ when Array then value.map { |item| jsonable(item) }
89
+ when Time then iso_utc(value)
90
+ when Symbol then value.to_s
91
+ else
92
+ if defined?(::DateTime) && value.is_a?(::DateTime)
93
+ iso_utc(value.to_time)
94
+ elsif defined?(::Date) && value.is_a?(::Date)
95
+ value.strftime("%Y-%m-%d")
96
+ else
97
+ value
98
+ end
99
+ end
100
+ end
101
+
102
+ # Texto UTF-8 (bytes inválidos trocados) → `[json_ou_nil, texto, é_json?]`.
103
+ def decode_json(raw)
104
+ text = raw.to_s.dup.force_encoding(::Encoding::UTF_8).scrub
105
+ return [nil, text, false] if text.strip.empty?
106
+
107
+ [JSON.parse(text), text, true]
108
+ rescue JSON::ParserError
109
+ [nil, text, false]
110
+ end
111
+ end
112
+ end