sendly 3.39.0 → 3.40.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c474ade97511ceecadbff15268b602edb67953395417996e9c1ac8b7626e68ff
4
- data.tar.gz: b034a8b316eab9f85e98de49e31db3045326662beeefbafb306844302a147037
3
+ metadata.gz: 91dd6ee6db6b506dea8d70b085fc6a069330e9ddca10b1de8e097b3db07889cd
4
+ data.tar.gz: 3114725bc7d45361b625ba7c7cfc88b6a6eee0672154b3b3daf46237cb475b53
5
5
  SHA512:
6
- metadata.gz: 10f0a1eecb587b5c0b801c1abb5366d41caaa4fecf7e274ac3a37860d82bbdf92ca644280a419626391d0fce53679c35172a66aca3f26893d780bb499321c15c
7
- data.tar.gz: 0221a6cfd5de361ff4648b4b4e0256ceccf4bab50e3b44b2616cd2200c136c2acd27409c3b20a99f2f81176663e0e272664a07d07f113d29d2b2064a0546bd33
6
+ metadata.gz: a8728f247769f3bbd8ad1713a86814eb419c944d64f06797ed94f858b543185d4c1baced35ba9aa910871b04a15e0348aece222a4e88de691e717df9b5542c01
7
+ data.tar.gz: ba2ad16077e383a0e37b7dee97ebada69cd3c1e815f6775afb677ab331a031da2f643666da3e95cc2b90e0dbff3478a6d35bc9a0364efd9b865c1ff5302f25fd
data/CHANGELOG.md CHANGED
@@ -2,8 +2,34 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### Breaking Changes
6
+
7
+ - **A webhook that is not a message is no longer presented as one.** `parse_event` built a `Sendly::WebhookMessageData` out of every `data.object`, whatever the event was. For an `rcs_*`, `whatsapp_*`, `call.*`, `brand.*`, `campaign.*`, `assignment.*`, `number.*`, `port*`, `contact*`, `conversation.*` or `draft.*` payload that dropped every field the event actually carried — `agent_id`, `stage`, `port_request_id`, `duration_secs` and the rest were unreachable — and filled the gaps with message fields that were never sent. `event.data` is now a `Sendly::WebhookObject`, a hash-like view of `data.object`: read a key with `[]` (String or Symbol), a reader method of the same name, or `to_h`. `event.message` is the message view and is `nil` for all of the above; `message.*` events are unchanged, and `event.data` is still the `WebhookMessageData` there.
8
+
9
+ ```ruby
10
+ # before — "" for every RCS event, and the agent was unreachable
11
+ event.data.from
12
+ # after
13
+ event.data[:agent_id] # => "bb22cc33-..."
14
+ event.message # => nil
15
+ ```
16
+
17
+ - **Absent fields are `nil` instead of a plausible-looking default.** `WebhookMessageData` defaulted `segments` to `1`, `credits_used` to `0`, `direction` to `"outbound"`, `from` to `""` and `id` to `""`, none of which a handler could tell from a real value. They are now `nil` when the payload did not carry them, and `data.key?(:segments)` says which case you are in. `WebhookVerificationData` loses the same kind of defaults (`delivery_status` `"queued"`, `attempts` `0`, `max_attempts` `3`), though nothing could reach that class before this release.
18
+
19
+ - **JSON `null` survives as `nil`.** `from` and `to` on a `call.*` event are `null` for every in-app call; `from` used to arrive as `""`. Code branching on `from.empty?` should branch on `nil` now.
20
+
21
+ - **`event.data.to_h` returns `data.object` as it arrived.** It used to return a compacted subset of the typed message fields, which dropped `text`, `metadata`, `media_urls`, `message_format` and `organization_id`, renamed `message_id` to `id`, and emitted the invented `segments`/`credits_used` defaults. `event.to_h[:data]` is the same hash. For a current-shape payload the familiar keys are all still there.
22
+
23
+ - **`id` is no longer filled from an unrelated `id` key.** `contact.auto_flagged` carries the contact under `id` and the message that failed under `message_id`, so `event.data.message_id` returned the *contact* id — a handler that marked that message failed acted on the wrong row. Contact events have no message view at all now; read the message with `event.data[:message_id]`.
24
+
5
25
  ### Minor Changes
6
26
 
27
+ - **`Sendly::WebhookEvent#raw_object`** carries `data.object` exactly as it arrived, for every event type, and **`#object_as(klass)`** reads it as a type of your choosing (`event.object_as(AgentLive)`). `#object` is an alias for `raw_object`.
28
+
29
+ - **`Sendly::WebhookVerificationData` is reachable.** Nothing ever constructed it, and it read String keys while `parse_event` symbolizes names, so it could not have worked if anything had. `verification.*` events now build one, as `event.verification` and `event.data`, and every reader takes String or Symbol keys.
30
+
31
+ - **`Sendly::WebhookEvent` gains `#message?` and `#verification?`** for the two cases that have a typed view.
32
+
7
33
  - **`Sendly::ValidationError#field_errors` is now populated.** It was always `nil` before, because the API path never passed it. It now carries the response body's `errors` array on any 400 or 422, on every resource rather than just RCS: `client.contacts.import` already returns one, for example. Each entry is a Hash. Code that treats a truthy `field_errors` as "this only happens for X" should be rechecked.
8
34
 
9
35
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sendly (3.39.0)
4
+ sendly (3.40.0)
5
5
  faraday (~> 2.0)
6
6
  faraday-retry (~> 2.0)
7
7
 
data/README.md CHANGED
@@ -308,6 +308,8 @@ Full details: https://sendly.live/docs/idempotency
308
308
 
309
309
  ## Webhooks
310
310
 
311
+ ### Managing endpoints
312
+
311
313
  ```ruby
312
314
  # Create a webhook endpoint
313
315
  webhook = client.webhooks.create(
@@ -340,6 +342,67 @@ rotation = client.webhooks.rotate_secret("whk_xxx")
340
342
  client.webhooks.delete("whk_xxx")
341
343
  ```
342
344
 
345
+ ### Receiving events
346
+
347
+ `Sendly::Webhooks.parse_event` verifies the signature and returns a
348
+ `Sendly::WebhookEvent`. Pass the raw request body — not a re-serialized hash,
349
+ which would no longer match the signature.
350
+
351
+ ```ruby
352
+ event = Sendly::Webhooks.parse_event(
353
+ request.raw_post,
354
+ request.headers["X-Sendly-Signature"],
355
+ ENV.fetch("SENDLY_WEBHOOK_SECRET"),
356
+ timestamp: request.headers["X-Sendly-Timestamp"]
357
+ )
358
+ ```
359
+
360
+ `event.raw_object` is `data.object` exactly as it arrived, for every event
361
+ type. `event.data` is a hash-like view of the same object: read a key with
362
+ `[]` (String or Symbol), a reader method of the same name, or `to_h`.
363
+
364
+ ```ruby
365
+ case event.type
366
+ when Sendly::Webhooks::EVENT_MESSAGE_DELIVERED
367
+ # message.* events also get a typed message view
368
+ puts "#{event.message.id} -> #{event.message.to}"
369
+ when Sendly::Webhooks::EVENT_RCS_AGENT_LIVE
370
+ puts event.data[:agent_id]
371
+ puts event.data.stage
372
+ when Sendly::Webhooks::EVENT_CALL_COMPLETED
373
+ puts event.data[:duration_secs]
374
+ end
375
+
376
+ # Or read data.object as a type of your own. A Struct or Data class is filled
377
+ # from the members it declares and ignores the rest of the payload, so a field
378
+ # added to the event later cannot break the call.
379
+ AgentLive = Struct.new(:agent_id, :name, :stage)
380
+ agent = event.object_as(AgentLive)
381
+ ```
382
+
383
+ Two things the SDK will not do, because both make a handler act on data that
384
+ was never sent:
385
+
386
+ - **Nothing is invented.** A field the payload did not carry is `nil`, and
387
+ `event.data.key?(:segments)` is `false`. `segments`, `credits_used`,
388
+ `direction`, `to` and `from` are no longer defaulted to `1`, `0`,
389
+ `"outbound"` and `""`.
390
+ - **`nil` stays `nil`.** An in-app `call.*` event carries `from` and `to` as
391
+ JSON `null`; they come back as `nil`, not `""`.
392
+
393
+ `event.message` is the message view and is `nil` for every event that is not a
394
+ message — `rcs_*`, `whatsapp_*`, `call.*`, `brand.*`, `campaign.*`,
395
+ `assignment.*`, `number.*`, `port*`, `contact*`, `conversation.*` and
396
+ `draft.*`, whose payloads are not message-shaped. `verification.*` events get
397
+ `event.verification`, a `Sendly::WebhookVerificationData`. `event.data` is the
398
+ typed view where one exists and a plain `Sendly::WebhookObject` otherwise, so
399
+ reading `data.object` works the same way for all of them, including an event
400
+ type this SDK predates.
401
+
402
+ Note that `contact.auto_flagged` carries the contact under `id` and the message
403
+ that failed under `message_id`; read the message with
404
+ `event.data[:message_id]`.
405
+
343
406
  ## Account & Credits
344
407
 
345
408
  ```ruby
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sendly
4
- VERSION = "3.39.0"
4
+ VERSION = "3.40.0"
5
5
  end
@@ -20,9 +20,13 @@ module Sendly
20
20
  #
21
21
  # case event.type
22
22
  # when 'message.delivered'
23
- # puts "Message delivered: #{event.data.id}"
23
+ # puts "Message delivered: #{event.message.id}"
24
24
  # when 'message.failed'
25
- # puts "Message failed: #{event.data.error}"
25
+ # puts "Message failed: #{event.message.error}"
26
+ # when 'rcs_agent.live'
27
+ # # A lifecycle payload is not message-shaped. event.message is nil
28
+ # # for it; read data.object through event.data or event.raw_object.
29
+ # puts "RCS agent live: #{event.data[:agent_id]}"
26
30
  # end
27
31
  #
28
32
  # head :ok
@@ -36,9 +40,13 @@ module Sendly
36
40
 
37
41
  # Webhook event type string constants. Use these when subscribing
38
42
  # instead of string literals so you catch typos at load time.
43
+ # Deprecated: the API has never emitted this and rejects it when you
44
+ # subscribe. It will be removed in the next major version.
39
45
  EVENT_MESSAGE_QUEUED = "message.queued"
46
+
40
47
  EVENT_MESSAGE_SENT = "message.sent"
41
48
  EVENT_MESSAGE_DELIVERED = "message.delivered"
49
+ EVENT_MESSAGE_READ = "message.read"
42
50
  EVENT_MESSAGE_FAILED = "message.failed"
43
51
  EVENT_MESSAGE_BOUNCED = "message.bounced"
44
52
  EVENT_MESSAGE_RETRYING = "message.retrying"
@@ -52,6 +60,11 @@ module Sendly
52
60
  EVENT_VERIFICATION_FAILED = "verification.failed"
53
61
  EVENT_VERIFICATION_RESENT = "verification.resent"
54
62
  EVENT_VERIFICATION_DELIVERY_FAILED = "verification.delivery_failed"
63
+ EVENT_CONVERSATION_CREATED = "conversation.created"
64
+ EVENT_CONVERSATION_UPDATED = "conversation.updated"
65
+ EVENT_DRAFT_CREATED = "draft.created"
66
+ EVENT_DRAFT_APPROVED = "draft.approved"
67
+ EVENT_DRAFT_REJECTED = "draft.rejected"
55
68
  EVENT_CONTACT_AUTO_FLAGGED = "contact.auto_flagged"
56
69
  EVENT_CONTACT_MARKED_VALID = "contact.marked_valid"
57
70
  EVENT_CONTACTS_LOOKUP_COMPLETED = "contacts.lookup_completed"
@@ -63,6 +76,12 @@ module Sendly
63
76
  EVENT_CAMPAIGN_SUSPENDED = "campaign.suspended"
64
77
  EVENT_ASSIGNMENT_CONFIRMED = "assignment.confirmed"
65
78
  EVENT_ASSIGNMENT_FAILED = "assignment.failed"
79
+ EVENT_RCS_BRAND_VERIFIED = "rcs_brand.verified"
80
+ EVENT_RCS_BRAND_FAILED = "rcs_brand.failed"
81
+ EVENT_RCS_AGENT_TESTING = "rcs_agent.testing"
82
+ EVENT_RCS_AGENT_LIVE = "rcs_agent.live"
83
+ EVENT_RCS_AGENT_REJECTED = "rcs_agent.rejected"
84
+ EVENT_RCS_AGENT_ACTION_REQUIRED = "rcs_agent.action_required"
66
85
  EVENT_PORT_COMPLETED = "port.completed"
67
86
  EVENT_PORT_OUT_REQUESTED = "port_out.requested"
68
87
  EVENT_PORT_OUT_COMPLETED = "port_out.completed"
@@ -72,6 +91,14 @@ module Sendly
72
91
  EVENT_NUMBER_FAILED = "number.failed"
73
92
  EVENT_NUMBER_REQUIREMENTS_REQUIRED = "number.requirements_required"
74
93
  EVENT_NUMBER_RELEASED = "number.released"
94
+ EVENT_WHATSAPP_ACCOUNT_CONNECTED = "whatsapp_account.connected"
95
+ EVENT_WHATSAPP_ACCOUNT_FAILED = "whatsapp_account.failed"
96
+ EVENT_WHATSAPP_TEMPLATE_APPROVED = "whatsapp_template.approved"
97
+ EVENT_WHATSAPP_TEMPLATE_REJECTED = "whatsapp_template.rejected"
98
+ EVENT_WHATSAPP_TEMPLATE_PAUSED = "whatsapp_template.paused"
99
+ EVENT_CALL_STARTED = "call.started"
100
+ EVENT_CALL_COMPLETED = "call.completed"
101
+ EVENT_CALL_RECORDING_READY = "call.recording.ready"
75
102
 
76
103
  # Source of a list-health event. Frozen enum — new values will be
77
104
  # added in minor SDK versions, never removed.
@@ -163,105 +190,369 @@ module Sendly
163
190
  end
164
191
  end
165
192
 
166
- class WebhookEvent
167
- attr_reader :id, :type, :data, :created, :api_version, :livemode
193
+ # A hash-like view of a webhook event's +data.object+.
194
+ #
195
+ # Every key the payload carried is reachable — by +[]+ with a String or a
196
+ # Symbol, by {#to_h}, or as a reader method of the same name — and nothing
197
+ # else is. A key the payload did not carry is absent rather than filled in
198
+ # with a plausible-looking default, and a key that arrived as JSON +null+
199
+ # stays +nil+.
200
+ #
201
+ # @example An rcs_agent.live payload
202
+ # event.data[:agent_id] # => "bb22cc33-dd44-4e55-9f66-001122334455"
203
+ # event.data.stage # => "live"
204
+ # event.data.key?(:from) # => false — an RCS agent event has no from
205
+ class WebhookObject
206
+ include Enumerable
168
207
 
169
- def initialize(data)
170
- @id = data[:id]
171
- @type = data[:type]
172
- obj = data[:data][:object] || data[:data]
173
- @data = WebhookMessageData.new(obj)
174
- @created = data[:created] || data[:created_at] || 0
175
- @api_version = data[:api_version] || '2024-01'
176
- @livemode = data[:livemode] || false
208
+ # Names that must keep Ruby's meaning even if a payload carries them as a
209
+ # key. Overriding these on an instance breaks object identity, equality or
210
+ # dispatch, so a reader is never defined for them — reach those keys with
211
+ # +[]+, +fetch+ or +to_h+, which always read the payload.
212
+ RESERVED = %i[
213
+ __send__ __id__ object_id class singleton_class method methods
214
+ instance_variable_get instance_variable_set instance_variables
215
+ respond_to? equal? is_a? kind_of? instance_of? nil? tap raw
216
+ ].freeze
217
+
218
+ # @return [Hash] +data.object+ exactly as it arrived
219
+ attr_reader :raw
220
+
221
+ # @param raw [Hash] the parsed +data.object+
222
+ def initialize(raw = {})
223
+ @raw = raw.is_a?(Hash) ? raw : {}
224
+ define_payload_readers
177
225
  end
178
226
 
179
- def created_at
180
- @created
227
+ # @param key [String, Symbol]
228
+ # @return [Object, nil] nil when the payload did not carry the key
229
+ def [](key)
230
+ resolved = resolve_key(key)
231
+ resolved.nil? ? nil : @raw[resolved]
232
+ end
233
+
234
+ # @param key [String, Symbol]
235
+ # @return [Object] the value, the default, or the block's result
236
+ # @raise [KeyError] if the key is absent and no default was given
237
+ def fetch(key, *default, &block)
238
+ resolved = resolve_key(key)
239
+ return @raw[resolved] unless resolved.nil?
240
+ return default.first unless default.empty?
241
+ return block.call(key) if block
242
+
243
+ raise KeyError, "key not found: #{key.inspect}"
244
+ end
245
+
246
+ def dig(key, *rest)
247
+ value = self[key]
248
+ return value if rest.empty? || value.nil?
249
+
250
+ value.dig(*rest)
181
251
  end
182
252
 
253
+ # @return [Boolean] whether the payload carried this key at all. Use it to
254
+ # tell "absent" from "arrived as null".
255
+ def key?(key)
256
+ !resolve_key(key).nil?
257
+ end
258
+ alias has_key? key?
259
+
260
+ def keys
261
+ @raw.keys
262
+ end
263
+
264
+ def values
265
+ @raw.values
266
+ end
267
+
268
+ def each(&block)
269
+ @raw.each(&block)
270
+ end
271
+
272
+ def empty?
273
+ @raw.empty?
274
+ end
275
+
276
+ def size
277
+ @raw.size
278
+ end
279
+ alias length size
280
+
281
+ # @return [Hash] a copy of +data.object+. Absent keys stay absent and
282
+ # nulls stay nil; nothing is added.
183
283
  def to_h
184
- {
185
- id: @id,
186
- type: @type,
187
- data: @data.to_h,
188
- created: @created,
189
- api_version: @api_version,
190
- livemode: @livemode
191
- }
284
+ @raw.dup
285
+ end
286
+
287
+ def ==(other)
288
+ case other
289
+ when WebhookObject then @raw == other.raw
290
+ when Hash then @raw == other
291
+ else false
292
+ end
293
+ end
294
+
295
+ def inspect
296
+ "#<#{self.class.name} #{@raw.inspect}>"
297
+ end
298
+
299
+ # @return [Array<Symbol>] payload keys that cannot be read as a method
300
+ # because doing so would override Ruby's own semantics. Read them with
301
+ # +[]+ instead.
302
+ def reserved_keys
303
+ @raw.keys.map { |k| k.to_sym rescue nil }.compact & RESERVED
304
+ end
305
+
306
+ private
307
+
308
+ # Payload keys win over inherited methods.
309
+ #
310
+ # Reader access used to go through method_missing, which only fires when
311
+ # nothing else answers — so any key colliding with an Object or Enumerable
312
+ # method was silently shadowed. `contacts.bulk_marked_valid` really carries
313
+ # `count`, so `event.data.count` returned the NUMBER OF KEYS instead of the
314
+ # value, and respond_to?(:count) was true, giving the caller no signal.
315
+ # Defining a singleton reader per key makes the payload authoritative.
316
+ def define_payload_readers
317
+ singleton = singleton_class
318
+ @raw.each_key do |key|
319
+ name = begin
320
+ key.to_sym
321
+ rescue StandardError
322
+ next
323
+ end
324
+ next if RESERVED.include?(name)
325
+ next unless name.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*[?!]?\z/)
326
+
327
+ singleton.define_method(name) { @raw[key] }
328
+ end
329
+ end
330
+
331
+ def resolve_key(key)
332
+ return key if @raw.key?(key)
333
+
334
+ case key
335
+ when Symbol then @raw.key?(key.to_s) ? key.to_s : nil
336
+ when String then @raw.key?(key.to_sym) ? key.to_sym : nil
337
+ end
338
+ end
339
+
340
+ def method_missing(name, *args, &block)
341
+ key = args.empty? && block.nil? ? resolve_key(name) : nil
342
+ return @raw[key] unless key.nil?
343
+
344
+ carries = @raw.empty? ? 'no fields' : @raw.keys.map(&:to_s).join(', ')
345
+ raise NoMethodError.new(
346
+ "undefined method '#{name}' for #{self.class.name}: " \
347
+ "this event's data.object carries #{carries}",
348
+ name
349
+ )
350
+ end
351
+
352
+ def respond_to_missing?(name, include_private = false)
353
+ !resolve_key(name).nil? || super
192
354
  end
193
355
  end
194
356
 
195
- class WebhookMessageData
357
+ # The message view of +data.object+, built only for +message.*+ events.
358
+ #
359
+ # Readers return exactly what the payload carried. An absent field is +nil+:
360
+ # this class does not invent +segments+, +credits_used+, +direction+, +to+
361
+ # or +from+.
362
+ class WebhookMessageData < WebhookObject
196
363
  attr_reader :id, :status, :to, :from, :direction, :organization_id,
197
364
  :text, :error, :error_code, :delivered_at, :failed_at,
198
- :created_at, :segments, :credits_used, :message_format, :media_urls,
199
- :retry_count, :metadata, :batch_id
200
-
201
- def initialize(data)
202
- @id = data[:id] || data[:message_id] || ''
203
- @status = data[:status]
204
- @to = data[:to]
205
- @from = data[:from] || ''
206
- @direction = data[:direction] || 'outbound'
207
- @organization_id = data[:organization_id]
208
- @text = data[:text]
209
- @error = data[:error]
210
- @error_code = data[:error_code]
211
- @delivered_at = data[:delivered_at]
212
- @failed_at = data[:failed_at]
213
- @created_at = data[:created_at]
214
- @segments = data[:segments] || 1
215
- @credits_used = data[:credits_used] || 0
216
- @message_format = data[:message_format]
217
- @media_urls = data[:media_urls]
218
- @retry_count = data[:retry_count]
219
- @metadata = data[:metadata]
220
- @batch_id = data[:batch_id]
365
+ :created_at, :segments, :credits_used, :message_format,
366
+ :media_urls, :retry_count, :metadata, :batch_id
367
+
368
+ def initialize(data = {})
369
+ super
370
+ # Both spellings name the same message: the current payload shape uses
371
+ # `id`, the pre-`data.object` shape used `message_id`.
372
+ @id = self[:id] || self[:message_id]
373
+ @status = self[:status]
374
+ @to = self[:to]
375
+ @from = self[:from]
376
+ @direction = self[:direction]
377
+ @organization_id = self[:organization_id]
378
+ @text = self[:text]
379
+ @error = self[:error]
380
+ @error_code = self[:error_code]
381
+ @delivered_at = self[:delivered_at]
382
+ @failed_at = self[:failed_at]
383
+ @created_at = self[:created_at]
384
+ @segments = self[:segments]
385
+ @credits_used = self[:credits_used]
386
+ @message_format = self[:message_format]
387
+ @media_urls = self[:media_urls]
388
+ @retry_count = self[:retry_count]
389
+ @metadata = self[:metadata]
390
+ @batch_id = self[:batch_id]
221
391
  end
222
392
 
393
+ # Backwards-compatible alias for {#id}.
223
394
  def message_id
224
395
  @id
225
396
  end
226
-
227
- def to_h
228
- {
229
- id: @id,
230
- status: @status,
231
- to: @to,
232
- from: @from,
233
- direction: @direction,
234
- error: @error,
235
- error_code: @error_code,
236
- delivered_at: @delivered_at,
237
- failed_at: @failed_at,
238
- segments: @segments,
239
- credits_used: @credits_used,
240
- batch_id: @batch_id
241
- }.compact
242
- end
243
397
  end
244
398
 
245
- class WebhookVerificationData
399
+ # The verification view of +data.object+, built only for +verification.*+
400
+ # events. Readers carry what the payload held and nothing more.
401
+ class WebhookVerificationData < WebhookObject
246
402
  attr_reader :id, :organization_id, :phone, :status, :delivery_status,
247
403
  :attempts, :max_attempts, :expires_at, :verified_at,
248
404
  :created_at, :app_name, :template_id, :profile_id, :metadata
249
405
 
250
406
  def initialize(data = {})
251
- @id = data["id"]
252
- @organization_id = data["organization_id"]
253
- @phone = data["phone"]
254
- @status = data["status"]
255
- @delivery_status = data["delivery_status"] || "queued"
256
- @attempts = data["attempts"] || 0
257
- @max_attempts = data["max_attempts"] || 3
258
- @expires_at = data["expires_at"]
259
- @verified_at = data["verified_at"]
260
- @created_at = data["created_at"]
261
- @app_name = data["app_name"]
262
- @template_id = data["template_id"]
263
- @profile_id = data["profile_id"]
264
- @metadata = data["metadata"]
407
+ super
408
+ @id = self[:id]
409
+ @organization_id = self[:organization_id]
410
+ @phone = self[:phone]
411
+ @status = self[:status]
412
+ @delivery_status = self[:delivery_status]
413
+ @attempts = self[:attempts]
414
+ @max_attempts = self[:max_attempts]
415
+ @expires_at = self[:expires_at]
416
+ @verified_at = self[:verified_at]
417
+ @created_at = self[:created_at]
418
+ @app_name = self[:app_name]
419
+ @template_id = self[:template_id]
420
+ @profile_id = self[:profile_id]
421
+ @metadata = self[:metadata]
422
+ end
423
+ end
424
+
425
+ # A parsed webhook event.
426
+ #
427
+ # {#raw_object} is +data.object+ exactly as it arrived, for every event type.
428
+ # {#data} adds a typed view on top of it where one applies: a
429
+ # {WebhookMessageData} for +message.*+, a {WebhookVerificationData} for
430
+ # +verification.*+, and a plain {WebhookObject} for everything else —
431
+ # +rcs_*+, +whatsapp_*+, +call.*+, +brand.*+, +campaign.*+, +assignment.*+,
432
+ # +number.*+, +port*+, +contact*+, +conversation.*+ and +draft.*+, whose
433
+ # payloads are not message-shaped.
434
+ #
435
+ # @example Handling a lifecycle event
436
+ # case event.type
437
+ # when Sendly::Webhooks::EVENT_MESSAGE_DELIVERED
438
+ # puts event.message.id
439
+ # when Sendly::Webhooks::EVENT_RCS_AGENT_LIVE
440
+ # puts event.data[:agent_id]
441
+ # end
442
+ class WebhookEvent
443
+ MESSAGE_EVENT_PREFIX = 'message.'
444
+
445
+ # message.opt_in and message.opt_out share the message.* prefix but carry
446
+ # an opt-out record ({phone_number, keyword, from_number, timestamp}), not
447
+ # a message. Treating them as messages produced a message view with every
448
+ # field nil, which is the invented-value problem this class removes.
449
+ NON_MESSAGE_MESSAGE_EVENTS = ['message.opt_in', 'message.opt_out'].freeze
450
+ VERIFICATION_EVENT_PREFIX = 'verification.'
451
+
452
+ # @return [String] event id, for idempotency
453
+ attr_reader :id
454
+
455
+ # @return [String] event type, verbatim — including one this SDK predates
456
+ attr_reader :type
457
+
458
+ # @return [Hash] +data.object+ exactly as it arrived, for every event type
459
+ attr_reader :raw_object
460
+
461
+ # @return [WebhookObject] hash-like view of +data.object+. A
462
+ # {WebhookMessageData} for +message.*+ events and a
463
+ # {WebhookVerificationData} for +verification.*+ events; a plain
464
+ # {WebhookObject} otherwise.
465
+ attr_reader :data
466
+
467
+ # @return [WebhookMessageData, nil] the message view, or nil when this
468
+ # event is not a message. Lifecycle events return nil rather than a
469
+ # message struct full of invented values.
470
+ attr_reader :message
471
+
472
+ # @return [WebhookVerificationData, nil] the verification view, or nil
473
+ # when this event is not a +verification.*+ event
474
+ attr_reader :verification
475
+
476
+ attr_reader :created, :api_version, :livemode
477
+
478
+ def initialize(payload)
479
+ env = payload.is_a?(WebhookObject) ? payload : WebhookObject.new(payload)
480
+
481
+ @id = env[:id]
482
+ @type = env[:type]
483
+ @raw_object = extract_object(env[:data])
484
+ @created = env[:created] || env[:created_at] || 0
485
+ @api_version = env[:api_version] || '2024-01'
486
+ @livemode = env[:livemode] || false
487
+
488
+ @message = message? ? WebhookMessageData.new(@raw_object) : nil
489
+ @verification = verification? ? WebhookVerificationData.new(@raw_object) : nil
490
+ @data = @message || @verification || WebhookObject.new(@raw_object)
491
+ end
492
+
493
+ # @return [Hash] alias for {#raw_object}
494
+ def object
495
+ @raw_object
496
+ end
497
+
498
+ # Read +data.object+ as a type of your choosing — the supported way to
499
+ # handle a lifecycle payload with a typed object.
500
+ #
501
+ # A Struct or Data class is filled from the members it declares, and the
502
+ # rest of the payload is ignored, so a field added to the event later
503
+ # cannot break the call. Any other class is handed +raw_object+ itself.
504
+ #
505
+ # @example
506
+ # AgentLive = Struct.new(:agent_id, :name, :stage)
507
+ # agent = event.object_as(AgentLive) # => #<struct AgentLive ...>
508
+ #
509
+ # @param klass [Class] a Struct or Data class, or anything whose
510
+ # initializer takes a Hash
511
+ # @return [Object]
512
+ def object_as(klass)
513
+ return klass.new(@raw_object) unless klass.respond_to?(:members)
514
+
515
+ values = klass.members.map { |member| @data[member] }
516
+ begin
517
+ klass.new(*values)
518
+ rescue ArgumentError
519
+ klass.new(**klass.members.zip(values).to_h)
520
+ end
521
+ end
522
+
523
+ # @return [Boolean] whether this event carries a message
524
+ def message?
525
+ @type.to_s.start_with?(MESSAGE_EVENT_PREFIX) &&
526
+ !NON_MESSAGE_MESSAGE_EVENTS.include?(@type.to_s)
527
+ end
528
+
529
+ # @return [Boolean] whether this event carries a verification
530
+ def verification?
531
+ @type.to_s.start_with?(VERIFICATION_EVENT_PREFIX)
532
+ end
533
+
534
+ def created_at
535
+ @created
536
+ end
537
+
538
+ def to_h
539
+ {
540
+ id: @id,
541
+ type: @type,
542
+ data: @data.to_h,
543
+ created: @created,
544
+ api_version: @api_version,
545
+ livemode: @livemode
546
+ }
547
+ end
548
+
549
+ private
550
+
551
+ def extract_object(data)
552
+ return {} unless data.is_a?(Hash)
553
+
554
+ object = data.key?(:object) ? data[:object] : data['object']
555
+ object.is_a?(Hash) ? object : data
265
556
  end
266
557
  end
267
558
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sendly
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.39.0
4
+ version: 3.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sendly
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-06 00:00:00.000000000 Z
11
+ date: 2026-09-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday