sendly 3.37.0 → 3.38.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.
@@ -10,14 +10,66 @@ module Sendly
10
10
  @client = client
11
11
  end
12
12
 
13
- # Send an SMS message
13
+ # Send an SMS, WhatsApp, or RCS message
14
+ #
15
+ # Pass +channel: "whatsapp"+ to send on WhatsApp. WhatsApp sends require
16
+ # a live API key and a +from+ number with an active WhatsApp connection
17
+ # (see +client.whatsapp.signup+). Provide exactly one of +text+
18
+ # (free-form, max 4096 bytes), +media_urls+ (a single attachment;
19
+ # optional +text+ becomes its caption, max 1024 bytes), or +template+
20
+ # (an approved template). Free-form text and media only deliver inside
21
+ # an open 24-hour customer-service window — outside it the API responds
22
+ # 422 +whatsapp_window_closed+; send a template instead (check with
23
+ # +client.whatsapp.window+).
24
+ #
25
+ # Pass +channel: "rcs"+ to send on RCS. RCS sends require a live API key
26
+ # and a sendable RCS agent on your workspace (see +client.rcs.agents+).
27
+ # Provide exactly one of +text+ (free-form, optionally with
28
+ # +suggestions+ — suggested replies and URL actions) or +card+ (a rich
29
+ # card). +agent_id+ picks the sending agent when your workspace has more
30
+ # than one. Recipients that can't receive RCS get +text+ delivered as
31
+ # plain SMS (billed as SMS) unless +fallback_to_sms+ is false — the
32
+ # returned {Sendly::RcsMessage} discloses which channel delivered.
14
33
  #
15
34
  # @param to [String] Recipient phone number in E.164 format
16
- # @param text [String] Message content (max 1600 characters)
17
- # @param from [String] Sender ID or phone number (optional)
18
- # @param message_type [String] Message type: "marketing" (default) or "transactional"
35
+ # @param text [String] Message content (max 1600 characters for SMS);
36
+ # for WhatsApp, optional free-form text or the media caption
37
+ # @param from [String] Sender ID or phone number (optional for SMS);
38
+ # required for WhatsApp — must be a WhatsApp-connected number
39
+ # @param message_type [String] Message type: "marketing" (default) or "transactional" (SMS only)
19
40
  # @param metadata [Hash] Custom JSON metadata to attach to the message (max 4KB)
20
- # @return [Sendly::Message] The sent message
41
+ # @param media_urls [Array<String>] Media URLs to attach (WhatsApp accepts exactly one)
42
+ # @param channel [String] Message channel: omit (or "sms") for SMS,
43
+ # "whatsapp" for WhatsApp, "rcs" for RCS
44
+ # @param template [Hash] WhatsApp only: approved template to send, with
45
+ # :name, :language, and optional :variables ({ "1" => "Acme" }) and
46
+ # :buttons ([{ index: 0, variables: { "1" => "4821" } }]). Works
47
+ # regardless of the 24-hour window.
48
+ # @param agent_id [String] RCS only: the agent to send from. Optional
49
+ # when your workspace has exactly one sendable agent; required (the
50
+ # API responds 400 +rcs_agent_ambiguous+) when it has more.
51
+ # @param card [Hash] RCS only: a rich card, with :title, :description,
52
+ # and optional :mediaUrl (a public JPEG, PNG, or GIF), :orientation
53
+ # ("vertical" or "horizontal"), and :suggestions. Passed through
54
+ # verbatim, so use the camelCase keys shown. Cards have no SMS form
55
+ # and only deliver to RCS-capable recipients.
56
+ # @param suggestions [Array<Hash>] RCS only, alongside +text+: suggested
57
+ # replies and actions, each either
58
+ # { reply: { text: ..., postbackData: ... } } or
59
+ # { action: { text: ..., postbackData: ..., url: ... } }. Passed
60
+ # through verbatim, so use the camelCase keys shown. Suggestions have
61
+ # no SMS form and are dropped on a fallback.
62
+ # @param fallback_to_sms [Boolean] RCS only: deliver +text+ as plain SMS
63
+ # when the recipient can't receive RCS (default true). Pass false to
64
+ # fail with 422 +rcs_not_supported_for_recipient+ instead.
65
+ # @param idempotency_key [String] Idempotency key for this operation
66
+ # (1-255 printable ASCII characters). The SDK already generates a key
67
+ # per logical request automatically, so the server can dedupe the
68
+ # SDK's own retries. Supply your own key when you need idempotency
69
+ # across process restarts or your own retry loops — repeating a
70
+ # request with the same key within 24 hours returns the original
71
+ # response instead of executing again.
72
+ # @return [Sendly::Message, Sendly::WhatsAppMessage, Sendly::RcsMessage] The sent message
21
73
  #
22
74
  # @raise [Sendly::ValidationError] If parameters are invalid
23
75
  # @raise [Sendly::InsufficientCreditsError] If account has no credits
@@ -37,8 +89,93 @@ module Sendly
37
89
  # text: "Your verification code is 123456",
38
90
  # message_type: "transactional"
39
91
  # )
40
- def send(to:, text:, from: nil, message_type: nil, metadata: nil, media_urls: nil)
92
+ #
93
+ # @example WhatsApp free-form reply inside an open 24h window
94
+ # message = client.messages.send(
95
+ # channel: "whatsapp",
96
+ # to: "+15551234567",
97
+ # from: "+15559876543",
98
+ # text: "Your table is ready!"
99
+ # )
100
+ #
101
+ # @example WhatsApp template send — works regardless of the window
102
+ # message = client.messages.send(
103
+ # channel: "whatsapp",
104
+ # to: "+15551234567",
105
+ # from: "+15559876543",
106
+ # template: {
107
+ # name: "order_shipped",
108
+ # language: "en_US",
109
+ # variables: { "1" => "Acme Inc", "2" => "#4821" }
110
+ # }
111
+ # )
112
+ # puts message.whatsapp.kind # "template"
113
+ # puts message.credits_used # priced by country + category
114
+ #
115
+ # @example RCS text with suggested replies and actions
116
+ # message = client.messages.send(
117
+ # channel: "rcs",
118
+ # to: "+15551234567",
119
+ # text: "Your order has shipped! Want live updates?",
120
+ # suggestions: [
121
+ # { reply: { text: "Yes, notify me", postbackData: "notify_yes" } },
122
+ # { action: { text: "Track order", postbackData: "track",
123
+ # url: "https://acme.example/orders/4821" } }
124
+ # ]
125
+ # )
126
+ # puts message.channel # "rcs", or "sms" when it fell back
127
+ # puts message.fell_back? # true when delivered as plain SMS
128
+ #
129
+ # @example RCS rich card (RCS-capable recipients only)
130
+ # client.messages.send(
131
+ # channel: "rcs",
132
+ # to: "+15551234567",
133
+ # card: {
134
+ # title: "Spring collection",
135
+ # description: "New arrivals are in - take a look.",
136
+ # mediaUrl: "https://example.com/spring.jpg",
137
+ # orientation: "vertical"
138
+ # }
139
+ # )
140
+ def send(to:, text: nil, from: nil, message_type: nil, metadata: nil, media_urls: nil,
141
+ channel: nil, template: nil, agent_id: nil, card: nil, suggestions: nil,
142
+ fallback_to_sms: nil, idempotency_key: nil)
41
143
  validate_phone!(to)
144
+
145
+ if channel.to_s == "rcs"
146
+ has_text = !(text.nil? || text.to_s.empty?)
147
+ has_card = !card.nil?
148
+ raise ValidationError, "Provide exactly one of 'text' or 'card'" if has_text == has_card
149
+
150
+ body = { channel: "rcs", to: to }
151
+ body[:agentId] = agent_id if agent_id
152
+ body[:text] = text if has_text
153
+ body[:card] = card if has_card
154
+ body[:suggestions] = suggestions if suggestions
155
+ body[:fallbackToSms] = fallback_to_sms unless fallback_to_sms.nil?
156
+ body[:metadata] = metadata if metadata
157
+
158
+ response = client.post("/messages", body, idempotency_key: idempotency_key)
159
+ return RcsMessage.new(response)
160
+ end
161
+
162
+ if channel.to_s == "whatsapp"
163
+ validate_phone!(from)
164
+ has_media = media_urls.is_a?(Array) && !media_urls.empty?
165
+ if (text.nil? || text.empty?) && !has_media && template.nil?
166
+ raise ValidationError, "Provide 'text', 'media_urls', or 'template'"
167
+ end
168
+
169
+ body = { channel: "whatsapp", to: to, from: from }
170
+ body[:text] = text unless text.nil?
171
+ body[:mediaUrls] = media_urls if has_media
172
+ body[:template] = template if template
173
+ body[:metadata] = metadata if metadata
174
+
175
+ response = client.post("/messages", body, idempotency_key: idempotency_key)
176
+ return WhatsAppMessage.new(response)
177
+ end
178
+
42
179
  validate_text!(text)
43
180
 
44
181
  body = { to: to, text: text }
@@ -47,7 +184,7 @@ module Sendly
47
184
  body[:metadata] = metadata if metadata
48
185
  body[:mediaUrls] = media_urls if media_urls
49
186
 
50
- response = client.post("/messages", body)
187
+ response = client.post("/messages", body, idempotency_key: idempotency_key)
51
188
  # API returns message directly at top level
52
189
  Message.new(response)
53
190
  end
@@ -65,6 +202,8 @@ module Sendly
65
202
  # @param from [String] Sender ID or phone number (optional)
66
203
  # @param media_urls [Array<String>] Media URLs to attach (required unless text is provided)
67
204
  # @param message_type [String] Message type: "transactional" (default) or "marketing"
205
+ # @param idempotency_key [String] Idempotency key for this operation
206
+ # (1-255 printable ASCII characters, optional)
68
207
  # @return [Sendly::GroupMessage] The sent group message, including a group_message_id
69
208
  #
70
209
  # @raise [Sendly::ValidationError] If fewer than 2 / more than 8 recipients, or no body
@@ -77,7 +216,7 @@ module Sendly
77
216
  # )
78
217
  # puts group.id
79
218
  # puts group.group_message_id
80
- def send_group(to:, text: nil, from: nil, media_urls: nil, message_type: nil)
219
+ def send_group(to:, text: nil, from: nil, media_urls: nil, message_type: nil, idempotency_key: nil)
81
220
  unless to.is_a?(Array) && to.length >= 2
82
221
  raise ValidationError, "Group messaging requires at least 2 recipients in 'to'"
83
222
  end
@@ -94,7 +233,7 @@ module Sendly
94
233
  body[:mediaUrls] = media_urls if has_media
95
234
  body[:messageType] = message_type if message_type
96
235
 
97
- response = client.post("/messages/group", body)
236
+ response = client.post("/messages/group", body, idempotency_key: idempotency_key)
98
237
  GroupMessage.new(response)
99
238
  end
100
239
 
@@ -218,6 +357,8 @@ module Sendly
218
357
  # @param from [String] Sender ID or phone number (optional)
219
358
  # @param message_type [String] Message type: "marketing" (default) or "transactional"
220
359
  # @param metadata [Hash] Custom JSON metadata to attach to the message (max 4KB)
360
+ # @param idempotency_key [String] Idempotency key for this operation
361
+ # (1-255 printable ASCII characters, optional)
221
362
  # @return [Hash] The scheduled message
222
363
  #
223
364
  # @raise [Sendly::ValidationError] If parameters are invalid
@@ -229,7 +370,7 @@ module Sendly
229
370
  # scheduled_at: "2025-01-20T10:00:00Z"
230
371
  # )
231
372
  # puts scheduled["id"]
232
- def schedule(to:, text:, scheduled_at:, from: nil, message_type: nil, metadata: nil)
373
+ def schedule(to:, text:, scheduled_at:, from: nil, message_type: nil, metadata: nil, idempotency_key: nil)
233
374
  validate_phone!(to)
234
375
  validate_text!(text)
235
376
  raise ValidationError, "scheduled_at is required" if scheduled_at.nil? || scheduled_at.empty?
@@ -239,7 +380,7 @@ module Sendly
239
380
  body[:messageType] = message_type if message_type
240
381
  body[:metadata] = metadata if metadata
241
382
 
242
- client.post("/messages/schedule", body)
383
+ client.post("/messages/schedule", body, idempotency_key: idempotency_key)
243
384
  end
244
385
 
245
386
  # List scheduled messages
@@ -303,6 +444,8 @@ module Sendly
303
444
  # @param from [String] Sender ID or phone number (optional, applies to all)
304
445
  # @param message_type [String] Message type: "marketing" (default) or "transactional"
305
446
  # @param metadata [Hash] Shared metadata for all messages (max 4KB). Each message can also have its own metadata hash which takes priority.
447
+ # @param idempotency_key [String] Idempotency key for this operation
448
+ # (1-255 printable ASCII characters, optional)
306
449
  # @return [Hash] Batch response with batch_id and status
307
450
  #
308
451
  # @raise [Sendly::ValidationError] If parameters are invalid
@@ -316,7 +459,7 @@ module Sendly
316
459
  # ]
317
460
  # )
318
461
  # puts "Batch #{result['batchId']}: #{result['queued']} queued"
319
- def send_batch(messages:, from: nil, message_type: nil, metadata: nil)
462
+ def send_batch(messages:, from: nil, message_type: nil, metadata: nil, idempotency_key: nil)
320
463
  raise ValidationError, "Messages array is required" if messages.nil? || messages.empty?
321
464
 
322
465
  messages.each_with_index do |msg, i|
@@ -334,7 +477,11 @@ module Sendly
334
477
  body[:messageType] = message_type if message_type
335
478
  body[:metadata] = metadata if metadata
336
479
 
337
- client.post("/messages/batch", body)
480
+ # The batch endpoint dedupes header-less retries server-side by hashing
481
+ # the request content; an auto-generated key would bypass that net for
482
+ # identical cross-process re-runs, so only caller-supplied keys are sent.
483
+ client.post("/messages/batch", body, idempotency_key: idempotency_key,
484
+ auto_idempotency_key: false)
338
485
  end
339
486
 
340
487
  # Get batch status by ID
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendly
4
+ # An RCS agent registered for your workspace — the branded identity your
5
+ # RCS messages come from. +status+ moves +"draft"+ → +"submitted"+ →
6
+ # +"testing"+ (can send, but only to invited test devices) → +"approved"+
7
+ # (can send to everyone); +"suspended"+ agents cannot send. +sendable+ is
8
+ # true when the agent is fully provisioned and its status allows sending.
9
+ class RcsAgent
10
+ attr_reader :id, :name, :status, :use_case, :sendable, :created_at
11
+
12
+ STATUSES = %w[draft submitted testing approved suspended].freeze
13
+
14
+ def initialize(data)
15
+ @id = data["id"]
16
+ @name = data["name"]
17
+ @status = data["status"]
18
+ @use_case = data["useCase"] || data["use_case"]
19
+ @sendable = data["sendable"] || false
20
+ @created_at = data["createdAt"] || data["created_at"]
21
+ end
22
+
23
+ def sendable?
24
+ sendable
25
+ end
26
+
27
+ def approved?
28
+ status == "approved"
29
+ end
30
+
31
+ def to_h
32
+ {
33
+ id: id, name: name, status: status, use_case: use_case,
34
+ sendable: sendable, created_at: created_at
35
+ }.compact
36
+ end
37
+ end
38
+
39
+ # Whether a recipient's device and network can receive RCS. When
40
+ # +capable+ is false, a text send to this recipient falls back to plain
41
+ # SMS (unless the fallback is disabled) and a card send fails. +features+
42
+ # lists the feature tags the device reports.
43
+ class RcsCapability
44
+ attr_reader :to, :agent_id, :capable, :features
45
+
46
+ def initialize(data)
47
+ @to = data["to"]
48
+ @agent_id = data["agentId"] || data["agent_id"]
49
+ @capable = data["capable"] || false
50
+ @features = data["features"] || []
51
+ end
52
+
53
+ def capable?
54
+ capable
55
+ end
56
+
57
+ def to_h
58
+ { to: to, agent_id: agent_id, capable: capable, features: features }.compact
59
+ end
60
+ end
61
+
62
+ # RCS-specific details on a sent message. On a native RCS delivery +kind+
63
+ # is what was sent ("text" or "card") and +agent_name+ is the brand name
64
+ # recipients see. On an SMS fallback +requested_channel+ is "rcs" and
65
+ # +suggestions_dropped+ is true when suggested replies/actions were
66
+ # dropped (they have no SMS form).
67
+ class RcsMessageDetails
68
+ attr_reader :kind, :agent_id, :agent_name, :requested_channel,
69
+ :suggestions_dropped
70
+
71
+ KINDS = %w[text card].freeze
72
+
73
+ def initialize(data)
74
+ @kind = data["kind"]
75
+ @agent_id = data["agentId"] || data["agent_id"]
76
+ @agent_name = data["agentName"] || data["agent_name"]
77
+ @requested_channel = data["requestedChannel"] || data["requested_channel"]
78
+ @suggestions_dropped = data["suggestionsDropped"] || data["suggestions_dropped"] || false
79
+ end
80
+
81
+ def to_h
82
+ {
83
+ kind: kind, agent_id: agent_id, agent_name: agent_name,
84
+ requested_channel: requested_channel,
85
+ suggestions_dropped: suggestions_dropped
86
+ }.compact
87
+ end
88
+ end
89
+
90
+ # A message sent through the RCS channel. +channel+ is "rcs" when it was
91
+ # delivered as RCS, or "sms" when the recipient couldn't receive RCS and
92
+ # the message fell back to plain SMS — check with {#fell_back?}. On a
93
+ # fallback +fell_back_to+ is "sms" and +segments+/+credits_used+ are
94
+ # billed as SMS; +rcs+ carries the channel-specific details either way.
95
+ class RcsMessage
96
+ attr_reader :id, :channel, :fell_back_to, :message_format, :to, :from,
97
+ :text, :status, :segments, :credits_used, :rcs, :created_at,
98
+ :metadata
99
+
100
+ def initialize(data)
101
+ @id = data["id"]
102
+ @channel = data["channel"] || "rcs"
103
+ @fell_back_to = data["fellBackTo"] || data["fell_back_to"]
104
+ @message_format = data["message_format"] || data["messageFormat"] || @channel
105
+ @to = data["to"]
106
+ @from = data["from"]
107
+ @text = data["text"]
108
+ @status = data["status"]
109
+ @segments = data["segments"] || 1
110
+ @credits_used = data["creditsUsed"] || data["credits_used"] || 0
111
+ @rcs = data["rcs"] ? RcsMessageDetails.new(data["rcs"]) : nil
112
+ @created_at = data["createdAt"] || data["created_at"]
113
+ @metadata = data["metadata"]
114
+ end
115
+
116
+ def fell_back?
117
+ fell_back_to == "sms"
118
+ end
119
+
120
+ def delivered?
121
+ status == "delivered"
122
+ end
123
+
124
+ def failed?
125
+ status == "failed"
126
+ end
127
+
128
+ def to_h
129
+ {
130
+ id: id, channel: channel, fell_back_to: fell_back_to,
131
+ message_format: message_format, to: to, from: from, text: text,
132
+ status: status, segments: segments, credits_used: credits_used,
133
+ rcs: rcs&.to_h, created_at: created_at, metadata: metadata
134
+ }.compact
135
+ end
136
+ end
137
+
138
+ # List the RCS agents registered for your workspace.
139
+ class RcsAgentsResource
140
+ def initialize(client)
141
+ @client = client
142
+ end
143
+
144
+ # List your RCS agents, newest first. An empty list means no agent is
145
+ # set up yet — contact support to register an RCS agent for your brand.
146
+ #
147
+ # @return [Hash] +{ agents: Array<RcsAgent> }+
148
+ #
149
+ # @example
150
+ # client.rcs.agents.list[:agents].each do |a|
151
+ # puts "#{a.name} — #{a.status}#{a.sendable? ? ' (sendable)' : ''}"
152
+ # end
153
+ def list
154
+ response = @client.get("/rcs/agents")
155
+ agents = (response["agents"] || []).map { |a| RcsAgent.new(a) }
156
+ { agents: agents }
157
+ end
158
+ end
159
+
160
+ # RCS resource — discover your RCS agents and pre-flight recipient
161
+ # capability.
162
+ #
163
+ # RCS is a first-class Sendly channel: send branded rich messages — text
164
+ # with suggested replies and actions, or rich cards — via
165
+ # +client.messages.send(channel: "rcs", ...)+. RCS agents are registered
166
+ # by Sendly for your brand; contact support to set one up, then discover
167
+ # it with {RcsAgentsResource#list}.
168
+ #
169
+ # Delivery is per-recipient: not every device or network supports RCS.
170
+ # Text messages fall back to plain SMS automatically (billed as SMS)
171
+ # unless you pass +fallback_to_sms: false+; rich cards have no SMS form
172
+ # and only deliver to RCS-capable recipients. Use {#capability} to check
173
+ # a recipient before sending.
174
+ #
175
+ # RCS sends and capability checks require a live API key
176
+ # (+sk_live_v1_xxx+).
177
+ #
178
+ # @example Check capability, then send
179
+ # cap = client.rcs.capability(to: "+15551234567")
180
+ # if cap.capable?
181
+ # client.messages.send(
182
+ # channel: "rcs",
183
+ # to: "+15551234567",
184
+ # text: "Your order has shipped!"
185
+ # )
186
+ # end
187
+ class RcsResource
188
+ # @return [RcsAgentsResource] List registered agents
189
+ attr_reader :agents
190
+
191
+ def initialize(client)
192
+ @client = client
193
+ @agents = RcsAgentsResource.new(client)
194
+ end
195
+
196
+ # Check whether a recipient can receive RCS.
197
+ #
198
+ # Probes the recipient's device and network. When the recipient is not
199
+ # capable, a text send falls back to plain SMS (unless the fallback is
200
+ # disabled) and a card send fails with
201
+ # +rcs_not_supported_for_recipient+.
202
+ #
203
+ # @param to [String] The recipient's number, in E.164 format
204
+ # @param agent_id [String, nil] The agent to probe with. Optional when
205
+ # your workspace has exactly one sendable agent; required (the API
206
+ # responds 400 +rcs_agent_ambiguous+) when it has more.
207
+ # @return [RcsCapability]
208
+ #
209
+ # @example
210
+ # cap = client.rcs.capability(to: "+15551234567")
211
+ # puts cap.capable?
212
+ # puts cap.features.inspect
213
+ def capability(to:, agent_id: nil)
214
+ raise ValidationError, "to is required" if to.nil? || to.to_s.empty?
215
+
216
+ params = { to: to }
217
+ params[:agentId] = agent_id if agent_id
218
+ response = @client.get("/rcs/capability", params)
219
+ RcsCapability.new(response)
220
+ end
221
+ end
222
+ end