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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bzapper
4
+ # Versão da gem. O `scripts/release-sdks.sh` do monorepo bumpa a linha abaixo por regex
5
+ # (ancorada no início da linha) e o `bzapper.gemspec` lê daqui — não mude o formato dela.
6
+ VERSION = "0.7.0"
7
+ 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: []