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 +7 -0
- data/LICENSE +21 -0
- data/README.md +323 -0
- data/lib/bzapper/client.rb +122 -0
- data/lib/bzapper/codec.rb +112 -0
- data/lib/bzapper/errors.rb +126 -0
- data/lib/bzapper/resources/accounts.rb +394 -0
- data/lib/bzapper/resources/advanced.rb +388 -0
- data/lib/bzapper/resources/advisories.rb +39 -0
- data/lib/bzapper/resources/base.rb +56 -0
- data/lib/bzapper/resources/billing.rb +177 -0
- data/lib/bzapper/resources/campaigns.rb +273 -0
- data/lib/bzapper/resources/connect.rb +35 -0
- data/lib/bzapper/resources/contacts.rb +481 -0
- data/lib/bzapper/resources/conversations.rb +47 -0
- data/lib/bzapper/resources/groups.rb +252 -0
- data/lib/bzapper/resources/instances.rb +287 -0
- data/lib/bzapper/resources/messages.rb +920 -0
- data/lib/bzapper/resources/partner.rb +134 -0
- data/lib/bzapper/resources/pools.rb +69 -0
- data/lib/bzapper/resources/scheduling.rb +38 -0
- data/lib/bzapper/resources/system.rb +21 -0
- data/lib/bzapper/resources/usage.rb +42 -0
- data/lib/bzapper/resources/webhooks.rb +147 -0
- data/lib/bzapper/resources.rb +100 -0
- data/lib/bzapper/transport.rb +248 -0
- data/lib/bzapper/unset.rb +27 -0
- data/lib/bzapper/upload.rb +61 -0
- data/lib/bzapper/version.rb +7 -0
- data/lib/bzapper/webhook.rb +194 -0
- data/lib/bzapper.rb +30 -0
- metadata +74 -0
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
|