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
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bzapper
|
|
4
|
+
# URL de produção da API (padrão do {Client}). Em dev: `http://localhost:8080`.
|
|
5
|
+
DEFAULT_BASE_URL = "https://api.bzapper.com.br"
|
|
6
|
+
# Vai em `X-Bzapper-Client` e `User-Agent`. É por ele que a API sabe qual versão da SDK a
|
|
7
|
+
# conta roda — e dispara o aviso "atualize sua integração" só para quem precisa.
|
|
8
|
+
CLIENT_ID = "bzapper-ruby/#{VERSION}".freeze
|
|
9
|
+
# Segundos por tentativa.
|
|
10
|
+
DEFAULT_TIMEOUT = 30
|
|
11
|
+
# Novas tentativas além da primeira.
|
|
12
|
+
DEFAULT_MAX_RETRIES = 2
|
|
13
|
+
|
|
14
|
+
# Faz UMA chamada lógica: 1 tentativa + até `max_retries` novas tentativas, com os mesmos
|
|
15
|
+
# `X-Request-Id` e `Idempotency-Key`. Sem estado mutável entre chamadas (uma conexão por
|
|
16
|
+
# tentativa), então um {Client} pode ser compartilhado entre threads.
|
|
17
|
+
# @api private
|
|
18
|
+
class Transport
|
|
19
|
+
RETRY_STATUSES = [429, 502, 503, 504].freeze
|
|
20
|
+
WRITE_METHODS = %w[POST PUT PATCH DELETE].freeze
|
|
21
|
+
MAX_RETRY_AFTER = 60.0
|
|
22
|
+
MAX_BACKOFF = 8.0
|
|
23
|
+
|
|
24
|
+
# Métodos que "esperam" corpo: sem corpo, vão com `Content-Length: 0` (e sem Content-Type).
|
|
25
|
+
BODY_METHODS = %w[POST PUT PATCH].freeze
|
|
26
|
+
|
|
27
|
+
# Falhas de transporte que viram {NetworkError} (e são repetidas). `Net::OpenTimeout` e
|
|
28
|
+
# `Net::ReadTimeout` são `Timeout::Error`; `ECONNREFUSED`/`ECONNRESET` são `SystemCallError`.
|
|
29
|
+
NETWORK_ERRORS = [
|
|
30
|
+
SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError,
|
|
31
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError
|
|
32
|
+
].tap { |list| list << Zlib::Error if defined?(Zlib::Error) }.freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :base_url, :timeout, :max_retries, :locale, :project_id
|
|
35
|
+
# Espera entre tentativas (`#call(segundos)`). Substituível: os testes não dormem.
|
|
36
|
+
attr_accessor :sleeper
|
|
37
|
+
|
|
38
|
+
def initialize(api_key, base_url:, timeout:, max_retries:, locale: nil, project_id: nil, sleeper: nil)
|
|
39
|
+
@api_key = api_key
|
|
40
|
+
@base_url = base_url.sub(%r{/+\z}, "")
|
|
41
|
+
begin
|
|
42
|
+
@uri = URI.parse(@base_url)
|
|
43
|
+
rescue URI::InvalidURIError
|
|
44
|
+
@uri = nil
|
|
45
|
+
end
|
|
46
|
+
unless @uri.is_a?(URI::HTTP) && @uri.hostname && !@uri.hostname.empty?
|
|
47
|
+
raise ArgumentError, "Bzapper: base_url inválida #{base_url.inspect} (use http:// ou https://)."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@base_path = @uri.path.to_s
|
|
51
|
+
@timeout = timeout
|
|
52
|
+
@max_retries = max_retries
|
|
53
|
+
@locale = present(locale)
|
|
54
|
+
@project_id = present(project_id)
|
|
55
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# `Retry-After` em segundos (número ou data HTTP); `nil` se ausente/inválido.
|
|
59
|
+
# @return [Integer, Float, nil]
|
|
60
|
+
def self.parse_retry_after(raw)
|
|
61
|
+
return nil if raw.nil?
|
|
62
|
+
|
|
63
|
+
text = raw.to_s.strip
|
|
64
|
+
return nil if text.empty?
|
|
65
|
+
|
|
66
|
+
seconds =
|
|
67
|
+
if text.match?(/\A\d+(?:\.\d+)?\z/)
|
|
68
|
+
text.to_f
|
|
69
|
+
else
|
|
70
|
+
begin
|
|
71
|
+
Time.httpdate(text) - Time.now
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
return nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
return nil if seconds.nan? || seconds.infinite?
|
|
77
|
+
|
|
78
|
+
seconds = 0.0 if seconds.negative?
|
|
79
|
+
seconds == seconds.floor ? seconds.to_i : seconds
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `min(8, 0.5 × 2^tentativa)` s + jitter de até 25%.
|
|
83
|
+
def self.backoff(attempt)
|
|
84
|
+
base = [MAX_BACKOFF, 0.5 * (2**attempt)].min
|
|
85
|
+
base + (Random.rand * base * 0.25)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Espera antes da próxima tentativa: `Retry-After` (teto 60 s) ou backoff exponencial.
|
|
89
|
+
def retry_delay(attempt, retry_after)
|
|
90
|
+
seconds = self.class.parse_retry_after(retry_after)
|
|
91
|
+
return [seconds.to_f, MAX_RETRY_AFTER].min unless seconds.nil?
|
|
92
|
+
|
|
93
|
+
self.class.backoff(attempt)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Executa a chamada e devolve o JSON decodificado da resposta (`nil` sem corpo).
|
|
97
|
+
#
|
|
98
|
+
# `body: nil` significa SEM corpo JSON; `multipart:` é um {Upload}.
|
|
99
|
+
# @raise [Bzapper::Error]
|
|
100
|
+
def request(method, path, query: nil, body: nil, multipart: nil, idempotency_key: nil, timeout: nil)
|
|
101
|
+
method = method.to_s.upcase
|
|
102
|
+
per_try = timeout.nil? ? @timeout : timeout
|
|
103
|
+
unless per_try.is_a?(Numeric) && per_try.positive?
|
|
104
|
+
raise ArgumentError, "timeout precisa ser > 0 (segundos)."
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
target = @base_path + path + Codec.query_string(query)
|
|
108
|
+
request_id = SecureRandom.uuid.delete("-")
|
|
109
|
+
headers = {
|
|
110
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
111
|
+
"Accept" => "application/json",
|
|
112
|
+
"X-Bzapper-Client" => CLIENT_ID,
|
|
113
|
+
"User-Agent" => CLIENT_ID,
|
|
114
|
+
# Mesmo id em todas as tentativas desta chamada: é como o suporte correlaciona.
|
|
115
|
+
"X-Request-Id" => request_id
|
|
116
|
+
}
|
|
117
|
+
headers["Accept-Language"] = @locale if @locale
|
|
118
|
+
headers["X-Project-Id"] = @project_id if @project_id
|
|
119
|
+
if WRITE_METHODS.include?(method)
|
|
120
|
+
# Mesma chave em todas as tentativas: a API devolve a resposta original
|
|
121
|
+
# (Idempotent-Replayed: true) em vez de executar de novo.
|
|
122
|
+
key = idempotency_key.to_s
|
|
123
|
+
headers["Idempotency-Key"] = key.empty? ? SecureRandom.uuid : key
|
|
124
|
+
end
|
|
125
|
+
payload = nil
|
|
126
|
+
if multipart
|
|
127
|
+
payload, headers["Content-Type"] = multipart.encode
|
|
128
|
+
elsif !body.nil?
|
|
129
|
+
payload = JSON.generate(Codec.jsonable(body))
|
|
130
|
+
headers["Content-Type"] = "application/json"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
attempt = 0
|
|
134
|
+
while true # rubocop:disable Style/InfiniteLoop -- `loop` engoliria um StopIteration do sleeper
|
|
135
|
+
begin
|
|
136
|
+
status, resp_headers, raw = perform(method, target, headers, payload, per_try)
|
|
137
|
+
rescue *NETWORK_ERRORS => e
|
|
138
|
+
if attempt < @max_retries
|
|
139
|
+
@sleeper.call(self.class.backoff(attempt))
|
|
140
|
+
attempt += 1
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
raise NetworkError.new(
|
|
144
|
+
"NETWORK_ERROR: falha ao falar com #{@base_url} (#{e.class}: #{e.message})",
|
|
145
|
+
request_id: request_id
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
return decode(status, resp_headers, raw, request_id) if status.between?(200, 299)
|
|
150
|
+
|
|
151
|
+
if RETRY_STATUSES.include?(status) && attempt < @max_retries
|
|
152
|
+
@sleeper.call(retry_delay(attempt, resp_headers["retry-after"]))
|
|
153
|
+
attempt += 1
|
|
154
|
+
next
|
|
155
|
+
end
|
|
156
|
+
raise build_error(status, resp_headers, raw, request_id)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def inspect
|
|
161
|
+
"#<Bzapper::Transport base_url=#{@base_url.inspect}>"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def perform(method, target, headers, payload, timeout)
|
|
167
|
+
http = Net::HTTP.new(@uri.hostname, @uri.port)
|
|
168
|
+
http.use_ssl = @uri.scheme == "https"
|
|
169
|
+
http.open_timeout = timeout
|
|
170
|
+
http.read_timeout = timeout
|
|
171
|
+
http.write_timeout = timeout
|
|
172
|
+
# O Net::HTTP repete sozinho GET/PUT/DELETE uma vez em certas falhas; quem decide
|
|
173
|
+
# novas tentativas (e conta) é esta classe.
|
|
174
|
+
http.max_retries = 0
|
|
175
|
+
# Requisição genérica, e não Net::HTTP::Post/Put: sem corpo, aquelas viram `body = ""` +
|
|
176
|
+
# `Content-Type: application/x-www-form-urlencoded` por conta própria.
|
|
177
|
+
req = Net::HTTPGenericRequest.new(method, !payload.nil?, true, target, headers)
|
|
178
|
+
if payload.nil?
|
|
179
|
+
req["Content-Length"] = "0" if BODY_METHODS.include?(method)
|
|
180
|
+
else
|
|
181
|
+
req.body = payload
|
|
182
|
+
end
|
|
183
|
+
response = http.start { |conn| conn.request(req) }
|
|
184
|
+
resp_headers = {}
|
|
185
|
+
response.each_header { |name, value| resp_headers[name] = value }
|
|
186
|
+
[response.code.to_i, resp_headers, response.body.to_s]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# 2xx → o JSON inteiro (`nil` quando não há corpo, ex.: 204). Corpo não vazio que não é JSON
|
|
190
|
+
# → `INVALID_RESPONSE` — nunca "sucesso sem dados" calado (proxy que responde 200 com HTML).
|
|
191
|
+
def decode(status, headers, raw, sent_request_id)
|
|
192
|
+
payload, text, ok = Codec.decode_json(raw)
|
|
193
|
+
return nil if text.strip.empty?
|
|
194
|
+
return payload if ok
|
|
195
|
+
|
|
196
|
+
shown = text.strip[0, 200]
|
|
197
|
+
raise Error.new(
|
|
198
|
+
"INVALID_RESPONSE (HTTP #{status}): a resposta não é JSON: #{shown.inspect}",
|
|
199
|
+
code: "INVALID_RESPONSE",
|
|
200
|
+
status: status,
|
|
201
|
+
request_id: present(headers["x-request-id"]) || sent_request_id,
|
|
202
|
+
body: text
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Resposta fora de 2xx → erro. Corpo `{code, message, locale}`; `request_id`: header da
|
|
207
|
+
# resposta → o id que a SDK enviou.
|
|
208
|
+
def build_error(status, headers, raw, sent_request_id)
|
|
209
|
+
payload, text, ok = Codec.decode_json(raw)
|
|
210
|
+
code = nil
|
|
211
|
+
human = nil
|
|
212
|
+
locale = nil
|
|
213
|
+
|
|
214
|
+
if ok && payload.is_a?(Hash)
|
|
215
|
+
err = payload["code"]
|
|
216
|
+
err = payload["error"] unless err.is_a?(String) && !err.empty?
|
|
217
|
+
code = err if err.is_a?(String) && !err.empty?
|
|
218
|
+
msg = payload["message"]
|
|
219
|
+
human = msg if msg.is_a?(String) && !msg.empty?
|
|
220
|
+
locale = payload["locale"] if payload["locale"].is_a?(String)
|
|
221
|
+
elsif !text.strip.empty?
|
|
222
|
+
human = text.strip[0, 200]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
code ||= "HTTP_#{status}"
|
|
226
|
+
request_id = present(headers["x-request-id"]) || sent_request_id
|
|
227
|
+
retry_after = status == 429 ? self.class.parse_retry_after(headers["retry-after"]) : nil
|
|
228
|
+
required_scope = status == 403 ? present(headers["x-required-scope"]) : nil
|
|
229
|
+
|
|
230
|
+
# `message` do padrão: `body.message`, senão o `code` (detalhes em #detailed_message).
|
|
231
|
+
Error.class_for(status).new(
|
|
232
|
+
human || code,
|
|
233
|
+
code: code,
|
|
234
|
+
status: status,
|
|
235
|
+
request_id: request_id,
|
|
236
|
+
retry_after: retry_after,
|
|
237
|
+
required_scope: required_scope,
|
|
238
|
+
locale: locale,
|
|
239
|
+
body: ok ? payload : present(text)
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def present(value)
|
|
244
|
+
text = value.to_s
|
|
245
|
+
text.empty? ? nil : text
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bzapper
|
|
4
|
+
# Marca de argumento **não informado** — diferente de `nil`, que vai como `null` e LIMPA o
|
|
5
|
+
# campo na API. É o valor padrão dos keyword args opcionais de corpo; você nunca precisa
|
|
6
|
+
# usá-la. Existe uma única instância: {Bzapper::UNSET}.
|
|
7
|
+
class Unset
|
|
8
|
+
def inspect
|
|
9
|
+
"Bzapper::UNSET"
|
|
10
|
+
end
|
|
11
|
+
alias to_s inspect
|
|
12
|
+
|
|
13
|
+
# Continua sendo a mesma instância (comparação por identidade).
|
|
14
|
+
def dup
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Continua sendo a mesma instância (comparação por identidade).
|
|
19
|
+
def clone(freeze: true) # rubocop:disable Lint/UnusedMethodArgument
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Valor padrão dos keyword args opcionais de corpo: "não envie este campo".
|
|
25
|
+
UNSET = Unset.new.freeze
|
|
26
|
+
Unset.private_class_method :new
|
|
27
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bzapper
|
|
4
|
+
# Arquivo de um upload `multipart/form-data` (logo da marca, logo do projeto, mídia de
|
|
5
|
+
# campanha). Montado pela SDK a partir de `file:`, `filename:` e `content_type:`.
|
|
6
|
+
# @api private
|
|
7
|
+
class Upload
|
|
8
|
+
DEFAULT_CONTENT_TYPE = "application/octet-stream"
|
|
9
|
+
|
|
10
|
+
attr_reader :field, :data, :filename, :content_type
|
|
11
|
+
|
|
12
|
+
# @param field [String] nome do campo do formulário (`file`).
|
|
13
|
+
# @param file [String, IO, Pathname] bytes, um IO (lido inteiro agora) ou o caminho no disco.
|
|
14
|
+
# @param filename [String, nil] padrão: o nome do caminho/IO, senão `"file"`.
|
|
15
|
+
# @param content_type [String, nil] padrão: `application/octet-stream`.
|
|
16
|
+
# @raise [ArgumentError] arquivo ausente ou nome inválido.
|
|
17
|
+
def initialize(field, file, filename: nil, content_type: nil)
|
|
18
|
+
raise ArgumentError, "file é obrigatório (bytes, IO ou Pathname)." if file.nil? || file.equal?(UNSET)
|
|
19
|
+
|
|
20
|
+
guessed = nil
|
|
21
|
+
if defined?(::Pathname) && file.is_a?(::Pathname)
|
|
22
|
+
guessed = file.basename.to_s
|
|
23
|
+
@data = File.binread(file.to_s)
|
|
24
|
+
elsif file.respond_to?(:read)
|
|
25
|
+
guessed = File.basename(file.path.to_s) if file.respond_to?(:path) && file.path
|
|
26
|
+
@data = file.read.to_s.b
|
|
27
|
+
elsif file.is_a?(String)
|
|
28
|
+
@data = file.b
|
|
29
|
+
else
|
|
30
|
+
raise TypeError, "file precisa ser String (bytes), IO ou Pathname (recebeu #{file.class})."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@field = field
|
|
34
|
+
@filename = (filename || guessed).to_s
|
|
35
|
+
@filename = "file" if @filename.empty?
|
|
36
|
+
if @filename.match?(/[\r\n"]/)
|
|
37
|
+
raise ArgumentError, "filename não pode ter aspas nem quebra de linha: #{@filename.inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@content_type = (content_type || DEFAULT_CONTENT_TYPE).to_s
|
|
41
|
+
raise ArgumentError, "content_type inválido: #{@content_type.inspect}" if @content_type.match?(/[\r\n]/)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Corpo `multipart/form-data` → `[bytes, content_type_do_cabeçalho]`. A fronteira é gerada
|
|
45
|
+
# uma vez por chamada lógica (as novas tentativas reenviam os mesmos bytes).
|
|
46
|
+
def encode
|
|
47
|
+
boundary = "bzapper-#{SecureRandom.hex(16)}"
|
|
48
|
+
body = +"".b
|
|
49
|
+
body << "--#{boundary}\r\n".b
|
|
50
|
+
body << "Content-Disposition: form-data; name=\"#{@field}\"; filename=\"#{@filename}\"\r\n".b
|
|
51
|
+
body << "Content-Type: #{@content_type}\r\n\r\n".b
|
|
52
|
+
body << @data
|
|
53
|
+
body << "\r\n--#{boundary}--\r\n".b
|
|
54
|
+
[body, "multipart/form-data; boundary=#{boundary}"]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def inspect
|
|
58
|
+
"#<Bzapper::Upload field=#{@field.inspect} filename=#{@filename.inspect} bytes=#{@data.bytesize}>"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bzapper
|
|
4
|
+
# Recebimento de webhooks — local, sem rede.
|
|
5
|
+
#
|
|
6
|
+
# A API assina cada entrega com `X-Bzapper-Signature: sha256=<hex>`, onde o hex é
|
|
7
|
+
# `HMAC-SHA256(secret, corpo_cru)`. Também manda `X-Bzapper-Event-Id` e
|
|
8
|
+
# `X-Bzapper-Event-Type`. Verifique SEMPRE o corpo **cru** (os bytes recebidos), nunca o JSON
|
|
9
|
+
# re-serializado.
|
|
10
|
+
#
|
|
11
|
+
# @example Rack/Rails
|
|
12
|
+
# raw = request.body.read
|
|
13
|
+
# event = Bzapper::Webhook.construct_event(ENV["BZAPPER_WEBHOOK_SECRET"], raw,
|
|
14
|
+
# request.get_header("HTTP_X_BZAPPER_SIGNATURE"))
|
|
15
|
+
# puts event.type, event.payload["body"]
|
|
16
|
+
#
|
|
17
|
+
# Idempotência: cada evento traz um `event.id` estável — guarde os ids processados e ignore
|
|
18
|
+
# repetições (a API pode reentregar).
|
|
19
|
+
module Webhook
|
|
20
|
+
SIGNATURE_HEADER = "X-Bzapper-Signature"
|
|
21
|
+
EVENT_ID_HEADER = "X-Bzapper-Event-Id"
|
|
22
|
+
EVENT_TYPE_HEADER = "X-Bzapper-Event-Type"
|
|
23
|
+
PREFIX = "sha256="
|
|
24
|
+
|
|
25
|
+
# Tipos de evento que a API pode entregar (referência).
|
|
26
|
+
EVENT_TYPES = %w[
|
|
27
|
+
message.received message.sent message.delivered message.read message.failed
|
|
28
|
+
instance.connected instance.disconnected instance.banned instance.logged_out
|
|
29
|
+
instance.warming instance.status
|
|
30
|
+
group.joined group.left group.participant_added group.participant_removed
|
|
31
|
+
group.participant_promoted group.participant_demoted
|
|
32
|
+
group.subject_changed group.description_changed
|
|
33
|
+
connect.completed connect.suspended connect.resumed connect.revoked
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# Ciclo de vida de uma conexão do bZapper Connect — só no webhook do PARCEIRO.
|
|
37
|
+
CONNECT_EVENT_TYPES = %w[connect.completed connect.suspended connect.resumed connect.revoked].freeze
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# Assinatura esperada para um corpo (`sha256=<hex>`).
|
|
42
|
+
# @param secret [String] o segredo do webhook (devolvido uma vez por `create_webhook`).
|
|
43
|
+
# @param raw_body [String] o corpo cru.
|
|
44
|
+
# @return [String]
|
|
45
|
+
# @raise [ArgumentError] segredo vazio.
|
|
46
|
+
def sign(secret, raw_body)
|
|
47
|
+
raise ArgumentError, "secret do webhook é obrigatório." if secret.nil? || secret.to_s.empty?
|
|
48
|
+
|
|
49
|
+
PREFIX + OpenSSL::HMAC.hexdigest("SHA256", secret.to_s.b, raw_body.to_s.b)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# `true` sse a assinatura confere com o HMAC do corpo **cru**. Comparação em tempo constante.
|
|
53
|
+
# @param secret [String]
|
|
54
|
+
# @param raw_body [String] os bytes recebidos, exatamente.
|
|
55
|
+
# @param signature [String, nil] o valor do header `X-Bzapper-Signature`.
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
def verify(secret, raw_body, signature)
|
|
58
|
+
return false if secret.nil? || secret.to_s.empty?
|
|
59
|
+
return false if signature.nil? || signature.to_s.empty?
|
|
60
|
+
|
|
61
|
+
expected = sign(secret, raw_body)
|
|
62
|
+
given = signature.to_s.strip.b
|
|
63
|
+
return false unless given.bytesize == expected.bytesize
|
|
64
|
+
|
|
65
|
+
OpenSSL.fixed_length_secure_compare(expected, given)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Verifica a assinatura e decodifica o evento.
|
|
69
|
+
# @return [Event]
|
|
70
|
+
# @raise [Bzapper::SignatureError] assinatura ausente ou inválida — NÃO processe.
|
|
71
|
+
# @raise [JSON::ParserError] corpo assinado que não é JSON.
|
|
72
|
+
def construct_event(secret, raw_body, signature)
|
|
73
|
+
raise SignatureError, "assinatura de webhook inválida" unless verify(secret, raw_body, signature)
|
|
74
|
+
|
|
75
|
+
text = raw_body.to_s.dup.force_encoding(::Encoding::UTF_8)
|
|
76
|
+
Event.new(JSON.parse(text))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Evento de webhook decodificado (o envelope entregue). Campos desconhecidos continuam em
|
|
80
|
+
# {#raw}.
|
|
81
|
+
class Event
|
|
82
|
+
# @return [Hash] o envelope inteiro, como veio.
|
|
83
|
+
attr_reader :raw
|
|
84
|
+
|
|
85
|
+
def initialize(raw)
|
|
86
|
+
@raw = raw.is_a?(Hash) ? raw : {}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [String] id estável do evento (use para idempotência).
|
|
90
|
+
def id
|
|
91
|
+
@raw["event_id"].to_s
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @return [String] tipo do evento (`message.received`, `instance.connected`…).
|
|
95
|
+
def type
|
|
96
|
+
@raw["event_type"].to_s
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [String, nil]
|
|
100
|
+
def timestamp
|
|
101
|
+
@raw["timestamp"]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [String, nil]
|
|
105
|
+
def instance_id
|
|
106
|
+
@raw["instance_id"]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [String, nil] a correlação que você mandou no envio.
|
|
110
|
+
def client_reference
|
|
111
|
+
@raw["client_reference"]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Hash, nil] `{"jid", "name"}` quando o evento aconteceu num grupo.
|
|
115
|
+
def group
|
|
116
|
+
@raw["group"].is_a?(Hash) ? @raw["group"] : nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [Hash, nil] `{"jid", "lid", "name", "phone"}` de quem enviou/disparou.
|
|
120
|
+
def sender
|
|
121
|
+
@raw["sender"].is_a?(Hash) ? @raw["sender"] : nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Array<String>]
|
|
125
|
+
def mentions
|
|
126
|
+
Array(@raw["mentions"])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @return [Hash] dados específicos do tipo de evento.
|
|
130
|
+
def payload
|
|
131
|
+
@raw["payload"].is_a?(Hash) ? @raw["payload"] : {}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @return [Hash, nil] conexão do Connect (só no webhook do parceiro).
|
|
135
|
+
def connection
|
|
136
|
+
@raw["connection"].is_a?(Hash) ? @raw["connection"] : nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @return [Hash]
|
|
140
|
+
def to_h
|
|
141
|
+
@raw
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def [](key)
|
|
145
|
+
@raw[key.to_s]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def inspect
|
|
149
|
+
"#<Bzapper::Webhook::Event id=#{id.inspect} type=#{type.inspect}>"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Verifica, decodifica e despacha entregas para handlers por tipo de evento.
|
|
154
|
+
#
|
|
155
|
+
# @example
|
|
156
|
+
# router = Bzapper::Webhook::Router.new(ENV.fetch("BZAPPER_WEBHOOK_SECRET"))
|
|
157
|
+
# router.on("message.received") { |event| puts event.payload["body"] }
|
|
158
|
+
# router.handle(raw_body, signature) # SignatureError se não confere
|
|
159
|
+
class Router
|
|
160
|
+
def initialize(secret)
|
|
161
|
+
raise ArgumentError, "Bzapper::Webhook::Router: secret é obrigatório." if secret.nil? || secret.to_s.empty?
|
|
162
|
+
|
|
163
|
+
@secret = secret
|
|
164
|
+
@handlers = Hash.new { |hash, key| hash[key] = [] }
|
|
165
|
+
@any = []
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Registra um handler para um tipo de evento.
|
|
169
|
+
def on(event_type, callable = nil, &block)
|
|
170
|
+
@handlers[event_type.to_s] << (callable || block || raise(ArgumentError, "handler ausente"))
|
|
171
|
+
self
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Registra um handler para TODO evento.
|
|
175
|
+
def on_any(callable = nil, &block)
|
|
176
|
+
@any << (callable || block || raise(ArgumentError, "handler ausente"))
|
|
177
|
+
self
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Verifica + decodifica + despacha. Devolve o evento.
|
|
181
|
+
# @raise [Bzapper::SignatureError]
|
|
182
|
+
def handle(raw_body, signature)
|
|
183
|
+
event = Webhook.construct_event(@secret, raw_body, signature)
|
|
184
|
+
@handlers.fetch(event.type, []).each { |handler| handler.call(event) }
|
|
185
|
+
@any.each { |handler| handler.call(event) }
|
|
186
|
+
event
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def inspect
|
|
190
|
+
"#<Bzapper::Webhook::Router>"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
data/lib/bzapper.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SDK oficial em Ruby da API do bZapper (WhatsApp): mensagens, números, grupos, contatos,
|
|
4
|
+
# campanhas, webhooks e o bZapper Connect. Só biblioteca padrão (net/http, json, openssl,
|
|
5
|
+
# securerandom).
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# require "bzapper"
|
|
9
|
+
#
|
|
10
|
+
# client = Bzapper::Client.new(ENV.fetch("BZAPPER_API_KEY"))
|
|
11
|
+
# client.messages.send_text(to: "5511999990000", body: "Olá!")
|
|
12
|
+
module Bzapper
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require "json"
|
|
16
|
+
require "net/http"
|
|
17
|
+
require "openssl"
|
|
18
|
+
require "securerandom"
|
|
19
|
+
require "time"
|
|
20
|
+
require "uri"
|
|
21
|
+
|
|
22
|
+
require_relative "bzapper/version"
|
|
23
|
+
require_relative "bzapper/unset"
|
|
24
|
+
require_relative "bzapper/errors"
|
|
25
|
+
require_relative "bzapper/codec"
|
|
26
|
+
require_relative "bzapper/upload"
|
|
27
|
+
require_relative "bzapper/transport"
|
|
28
|
+
require_relative "bzapper/resources"
|
|
29
|
+
require_relative "bzapper/client"
|
|
30
|
+
require_relative "bzapper/webhook"
|
metadata
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bzapper
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.7.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Berni Software
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Mensagens (todos os tipos, OTP), números e QR, grupos, contatos, campanhas,
|
|
13
|
+
webhooks com verificação de assinatura e bZapper Connect. Zero dependências de runtime,
|
|
14
|
+
novas tentativas e idempotência automáticas.
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- LICENSE
|
|
20
|
+
- README.md
|
|
21
|
+
- lib/bzapper.rb
|
|
22
|
+
- lib/bzapper/client.rb
|
|
23
|
+
- lib/bzapper/codec.rb
|
|
24
|
+
- lib/bzapper/errors.rb
|
|
25
|
+
- lib/bzapper/resources.rb
|
|
26
|
+
- lib/bzapper/resources/accounts.rb
|
|
27
|
+
- lib/bzapper/resources/advanced.rb
|
|
28
|
+
- lib/bzapper/resources/advisories.rb
|
|
29
|
+
- lib/bzapper/resources/base.rb
|
|
30
|
+
- lib/bzapper/resources/billing.rb
|
|
31
|
+
- lib/bzapper/resources/campaigns.rb
|
|
32
|
+
- lib/bzapper/resources/connect.rb
|
|
33
|
+
- lib/bzapper/resources/contacts.rb
|
|
34
|
+
- lib/bzapper/resources/conversations.rb
|
|
35
|
+
- lib/bzapper/resources/groups.rb
|
|
36
|
+
- lib/bzapper/resources/instances.rb
|
|
37
|
+
- lib/bzapper/resources/messages.rb
|
|
38
|
+
- lib/bzapper/resources/partner.rb
|
|
39
|
+
- lib/bzapper/resources/pools.rb
|
|
40
|
+
- lib/bzapper/resources/scheduling.rb
|
|
41
|
+
- lib/bzapper/resources/system.rb
|
|
42
|
+
- lib/bzapper/resources/usage.rb
|
|
43
|
+
- lib/bzapper/resources/webhooks.rb
|
|
44
|
+
- lib/bzapper/transport.rb
|
|
45
|
+
- lib/bzapper/unset.rb
|
|
46
|
+
- lib/bzapper/upload.rb
|
|
47
|
+
- lib/bzapper/version.rb
|
|
48
|
+
- lib/bzapper/webhook.rb
|
|
49
|
+
homepage: https://bzapper.com.br
|
|
50
|
+
licenses:
|
|
51
|
+
- MIT
|
|
52
|
+
metadata:
|
|
53
|
+
homepage_uri: https://bzapper.com.br
|
|
54
|
+
source_code_uri: https://github.com/bernisoftware/bzapper-ruby
|
|
55
|
+
bug_tracker_uri: https://github.com/bernisoftware/bzapper-ruby/issues
|
|
56
|
+
documentation_uri: https://github.com/bernisoftware/bzapper-ruby#readme
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '3.0'
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 3.6.9
|
|
72
|
+
specification_version: 4
|
|
73
|
+
summary: SDK oficial da API do bZapper (WhatsApp)
|
|
74
|
+
test_files: []
|