sendly 3.38.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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sendly
4
- VERSION = "3.38.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.38.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-08-31 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