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 +4 -4
- data/CHANGELOG.md +26 -0
- data/Gemfile.lock +1 -1
- data/README.md +63 -0
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/webhooks.rb +369 -78
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 91dd6ee6db6b506dea8d70b085fc6a069330e9ddca10b1de8e097b3db07889cd
|
|
4
|
+
data.tar.gz: 3114725bc7d45361b625ba7c7cfc88b6a6eee0672154b3b3daf46237cb475b53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
data/lib/sendly/version.rb
CHANGED
data/lib/sendly/webhooks.rb
CHANGED
|
@@ -20,9 +20,13 @@ module Sendly
|
|
|
20
20
|
#
|
|
21
21
|
# case event.type
|
|
22
22
|
# when 'message.delivered'
|
|
23
|
-
# puts "Message delivered: #{event.
|
|
23
|
+
# puts "Message delivered: #{event.message.id}"
|
|
24
24
|
# when 'message.failed'
|
|
25
|
-
# puts "Message failed: #{event.
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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,
|
|
199
|
-
:retry_count, :metadata, :batch_id
|
|
200
|
-
|
|
201
|
-
def initialize(data)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
@
|
|
206
|
-
@
|
|
207
|
-
@
|
|
208
|
-
@
|
|
209
|
-
@
|
|
210
|
-
@
|
|
211
|
-
@
|
|
212
|
-
@
|
|
213
|
-
@
|
|
214
|
-
@
|
|
215
|
-
@
|
|
216
|
-
@
|
|
217
|
-
@
|
|
218
|
-
@
|
|
219
|
-
@
|
|
220
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
@
|
|
253
|
-
@
|
|
254
|
-
@
|
|
255
|
-
@
|
|
256
|
-
@
|
|
257
|
-
@
|
|
258
|
-
@
|
|
259
|
-
@
|
|
260
|
-
@
|
|
261
|
-
@
|
|
262
|
-
@
|
|
263
|
-
@
|
|
264
|
-
@
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-09-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|