sendly 3.34.0 → 3.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendly
4
+ # A newly minted branded short link, as returned by {LinksResource#create}.
5
+ class ShortLink
6
+ # @return [String] Short code (the segment after the domain, e.g. "Ab3xY7")
7
+ attr_reader :code
8
+
9
+ # @return [String] Full branded short URL to share (e.g. "https://sendly.live/l/Ab3xY7")
10
+ attr_reader :short_url
11
+
12
+ # @return [String] The destination the short link redirects to
13
+ attr_reader :destination_url
14
+
15
+ # @return [Hash] The raw parsed response
16
+ attr_reader :raw
17
+
18
+ def initialize(data)
19
+ @raw = data
20
+ @code = data["code"]
21
+ @short_url = data["shortUrl"] || data["short_url"]
22
+ @destination_url = data["destinationUrl"] || data["destination_url"]
23
+ end
24
+
25
+ def to_h
26
+ {
27
+ code: code, short_url: short_url, destination_url: destination_url
28
+ }.compact
29
+ end
30
+ end
31
+
32
+ # A short link with click analytics, as returned by {LinksResource#list}.
33
+ class ShortLinkListItem
34
+ attr_reader :code, :short_url, :destination_url, :brand_slug, :click_count,
35
+ :disabled, :last_country, :last_clicked_at, :created_at, :spark
36
+
37
+ def initialize(data)
38
+ @code = data["code"]
39
+ @short_url = data["shortUrl"] || data["short_url"]
40
+ @destination_url = data["destinationUrl"] || data["destination_url"]
41
+ @brand_slug = data["brandSlug"] || data["brand_slug"]
42
+ @click_count = data["clickCount"] || data["click_count"] || 0
43
+ @disabled = data["disabled"] || false
44
+ @last_country = data["lastCountry"] || data["last_country"]
45
+ @last_clicked_at = parse_time(data["lastClickedAt"] || data["last_clicked_at"])
46
+ @created_at = parse_time(data["createdAt"] || data["created_at"])
47
+ # 14-day daily click histogram, oldest first (today last)
48
+ @spark = data["spark"] || []
49
+ end
50
+
51
+ # @return [Boolean] Whether the link is disabled (its redirect returns 404)
52
+ def disabled?
53
+ disabled
54
+ end
55
+
56
+ def to_h
57
+ {
58
+ code: code, short_url: short_url, destination_url: destination_url,
59
+ brand_slug: brand_slug, click_count: click_count, disabled: disabled,
60
+ last_country: last_country, last_clicked_at: last_clicked_at&.iso8601,
61
+ created_at: created_at&.iso8601, spark: spark
62
+ }.compact
63
+ end
64
+
65
+ private
66
+
67
+ def parse_time(value)
68
+ return nil if value.nil?
69
+ Time.parse(value)
70
+ rescue ArgumentError
71
+ nil
72
+ end
73
+ end
74
+
75
+ # A page of short links with their click analytics.
76
+ class ShortLinkList
77
+ include Enumerable
78
+
79
+ # @return [Array<ShortLinkListItem>] The workspace's short links, newest first
80
+ attr_reader :links
81
+
82
+ # @return [Integer] Total number of short links in the workspace
83
+ attr_reader :total
84
+
85
+ def initialize(response)
86
+ @links = (response["links"] || []).map { |l| ShortLinkListItem.new(l) }
87
+ @total = response["total"] || @links.length
88
+ end
89
+
90
+ def each(&block)
91
+ links.each(&block)
92
+ end
93
+
94
+ def count
95
+ links.length
96
+ end
97
+
98
+ alias size count
99
+ alias length count
100
+
101
+ def empty?
102
+ links.empty?
103
+ end
104
+
105
+ def first
106
+ links.first
107
+ end
108
+
109
+ def last
110
+ links.last
111
+ end
112
+ end
113
+
114
+ # The code and new disabled state returned when a link's kill switch is toggled.
115
+ class ShortLinkStatus
116
+ # @return [String] Short code that was updated
117
+ attr_reader :code
118
+
119
+ # @return [Boolean] New disabled state
120
+ attr_reader :disabled
121
+
122
+ def initialize(data)
123
+ @code = data["code"]
124
+ @disabled = data["disabled"] || false
125
+ end
126
+
127
+ # @return [Boolean] Whether the link is now disabled
128
+ def disabled?
129
+ disabled
130
+ end
131
+
132
+ def to_h
133
+ { code: code, disabled: disabled }
134
+ end
135
+ end
136
+
137
+ # Links resource — branded URL shortening.
138
+ #
139
+ # Mint branded short links for a destination URL, list the links your
140
+ # workspace has created (with click analytics), and enable or disable an
141
+ # individual link (a per-link kill switch).
142
+ #
143
+ # Branded, owned-domain short links improve deliverability — carriers filter
144
+ # public shorteners — and give you click data.
145
+ #
146
+ # NOTE: URL shortening is not yet generally available. It is gated behind the
147
+ # +url_shortener+ rollout flag (currently founder-only); for API-key clients a
148
+ # flag-off account reads as a 404 {Sendly::NotFoundError} (the feature is
149
+ # absent) until the flag is on for your account.
150
+ #
151
+ # @example Shorten a URL
152
+ # link = client.links.create(url: "https://example.com/spring-sale?utm_source=sms")
153
+ # puts link.short_url # => "https://sendly.live/l/Ab3xY7"
154
+ #
155
+ # @example List your links with click counts
156
+ # result = client.links.list(limit: 20)
157
+ # result.each { |l| puts "#{l.short_url} -> #{l.destination_url} (#{l.click_count} clicks)" }
158
+ #
159
+ # @example Kill a link
160
+ # client.links.disable(link.code)
161
+ class LinksResource
162
+ def initialize(client)
163
+ @client = client
164
+ end
165
+
166
+ # Mint a branded short link for a destination URL. Uses your workspace's
167
+ # brand slug when one is configured.
168
+ #
169
+ # @param url [String] The destination URL to shorten (must be http/https)
170
+ # @return [Sendly::ShortLink] The short link (code, short_url, destination_url)
171
+ #
172
+ # @raise [Sendly::ValidationError] If url is missing or not an http/https URL
173
+ def create(url:)
174
+ validate_url!(url)
175
+
176
+ response = @client.post("/links", { url: url })
177
+ ShortLink.new(response)
178
+ end
179
+
180
+ # List the short links your workspace has created, newest first, with click
181
+ # counts and a 14-day daily click histogram.
182
+ #
183
+ # @param limit [Integer, nil] Maximum links to return (default 50, max 200)
184
+ # @param offset [Integer, nil] Number of links to skip (default 0)
185
+ # @return [Sendly::ShortLinkList] The links and a total count
186
+ def list(limit: nil, offset: nil)
187
+ params = {}
188
+ params[:limit] = limit if limit
189
+ params[:offset] = offset if offset
190
+
191
+ response = @client.get("/links", params)
192
+ ShortLinkList.new(response)
193
+ end
194
+
195
+ # Enable or disable a short link. A disabled link's redirect returns 404
196
+ # until it is re-enabled.
197
+ #
198
+ # @param code [String] The short link code
199
+ # @param disabled [Boolean] true to disable, false to re-enable
200
+ # @return [Sendly::ShortLinkStatus] The link's code and new disabled state
201
+ #
202
+ # @raise [Sendly::NotFoundError] If no such link exists in your workspace
203
+ def update(code, disabled:)
204
+ raise ValidationError, "Link code is required" if code.nil? || code.to_s.empty?
205
+
206
+ encoded_code = URI.encode_www_form_component(code)
207
+ response = @client.patch("/links/#{encoded_code}", { disabled: disabled })
208
+ ShortLinkStatus.new(response)
209
+ end
210
+
211
+ # Disable a short link (its redirect returns 404 until re-enabled).
212
+ # Convenience wrapper over {#update}.
213
+ #
214
+ # @param code [String] The short link code
215
+ # @return [Sendly::ShortLinkStatus]
216
+ def disable(code)
217
+ update(code, disabled: true)
218
+ end
219
+
220
+ # Re-enable a previously disabled short link. Convenience wrapper over
221
+ # {#update}.
222
+ #
223
+ # @param code [String] The short link code
224
+ # @return [Sendly::ShortLinkStatus]
225
+ def enable(code)
226
+ update(code, disabled: false)
227
+ end
228
+
229
+ private
230
+
231
+ # Client-side guard mirroring the server's http/https-only check.
232
+ def validate_url!(url)
233
+ raise ValidationError, "url is required" if url.nil? || url.to_s.empty?
234
+
235
+ return if url.is_a?(String) && (url.start_with?("http://") || url.start_with?("https://"))
236
+
237
+ raise ValidationError, "url must be an http:// or https:// URL"
238
+ end
239
+ end
240
+ end
@@ -14,6 +14,7 @@ module Sendly
14
14
  #
15
15
  # @param to [String] Recipient phone number in E.164 format
16
16
  # @param text [String] Message content (max 1600 characters)
17
+ # @param from [String] Sender ID or phone number (optional)
17
18
  # @param message_type [String] Message type: "marketing" (default) or "transactional"
18
19
  # @param metadata [Hash] Custom JSON metadata to attach to the message (max 4KB)
19
20
  # @return [Sendly::Message] The sent message
@@ -36,11 +37,12 @@ module Sendly
36
37
  # text: "Your verification code is 123456",
37
38
  # message_type: "transactional"
38
39
  # )
39
- def send(to:, text:, message_type: nil, metadata: nil, media_urls: nil)
40
+ def send(to:, text:, from: nil, message_type: nil, metadata: nil, media_urls: nil)
40
41
  validate_phone!(to)
41
42
  validate_text!(text)
42
43
 
43
44
  body = { to: to, text: text }
45
+ body[:from] = from if from
44
46
  body[:messageType] = message_type if message_type
45
47
  body[:metadata] = metadata if metadata
46
48
  body[:mediaUrls] = media_urls if media_urls
@@ -50,6 +52,89 @@ module Sendly
50
52
  Message.new(response)
51
53
  end
52
54
 
55
+ # Send a group MMS to 2-8 recipients (US/Canada only)
56
+ #
57
+ # Creates a multi-party MMS conversation: every recipient sees the others,
58
+ # and replies fan out to all participants. Group messaging is an A2P 10DLC
59
+ # capability — the sending number must be an MMS-enabled, 10DLC-registered
60
+ # number you own. Omit +from+ to use your workspace's default sender.
61
+ # Requires the +group_mms+ feature (and +enable_mms+ when sending media).
62
+ #
63
+ # @param to [Array<String>] 2-8 recipient phone numbers in E.164 format (US/CA only)
64
+ # @param text [String] Message content (required unless media_urls is provided)
65
+ # @param from [String] Sender ID or phone number (optional)
66
+ # @param media_urls [Array<String>] Media URLs to attach (required unless text is provided)
67
+ # @param message_type [String] Message type: "transactional" (default) or "marketing"
68
+ # @return [Sendly::GroupMessage] The sent group message, including a group_message_id
69
+ #
70
+ # @raise [Sendly::ValidationError] If fewer than 2 / more than 8 recipients, or no body
71
+ # @raise [Sendly::InsufficientCreditsError] If credit balance is too low (billed per recipient)
72
+ #
73
+ # @example
74
+ # group = client.messages.send_group(
75
+ # to: ["+14155551234", "+14155555678"],
76
+ # text: "Hey team - quick sync at noon?"
77
+ # )
78
+ # puts group.id
79
+ # puts group.group_message_id
80
+ def send_group(to:, text: nil, from: nil, media_urls: nil, message_type: nil)
81
+ unless to.is_a?(Array) && to.length >= 2
82
+ raise ValidationError, "Group messaging requires at least 2 recipients in 'to'"
83
+ end
84
+ raise ValidationError, "Group messaging supports at most 8 recipients" if to.length > 8
85
+
86
+ to.each { |recipient| validate_phone!(recipient) }
87
+
88
+ has_media = media_urls.is_a?(Array) && !media_urls.empty?
89
+ raise ValidationError, "Provide 'text' or 'media_urls'" if (text.nil? || text.empty?) && !has_media
90
+
91
+ body = { to: to }
92
+ body[:text] = text if text && !text.empty?
93
+ body[:from] = from if from
94
+ body[:mediaUrls] = media_urls if has_media
95
+ body[:messageType] = message_type if message_type
96
+
97
+ response = client.post("/messages/group", body)
98
+ GroupMessage.new(response)
99
+ end
100
+
101
+ # AI-enhance a draft message for clarity, compliance, and send-readiness
102
+ #
103
+ # Rewrites the supplied text into a single, polished SMS segment (<=160
104
+ # chars) and returns a short explanation of what changed. Pass +message_type+
105
+ # to steer the rewrite (e.g. "marketing" vs "transactional"); with no +text+
106
+ # it generates a suitable message for that type instead. At least one of
107
+ # +text+ or +message_type+ is required. Requires the +ai_classification+
108
+ # feature. When AI enhancement is unavailable, the response falls back to the
109
+ # original text with an empty explanation.
110
+ #
111
+ # @param text [String] Draft message text to rewrite (optional if message_type given)
112
+ # @param message_type [String] Message-type hint, e.g. "marketing" or "transactional"
113
+ # @return [Sendly::EnhancedMessage] The enhanced text, an explanation, and the model used
114
+ #
115
+ # @raise [Sendly::ValidationError] If neither text nor message_type is provided
116
+ # @raise [Sendly::NotFoundError] If AI enhancement is not enabled for the account
117
+ #
118
+ # @example
119
+ # result = client.messages.enhance(
120
+ # text: "hey come check out our sale this weekend",
121
+ # message_type: "marketing"
122
+ # )
123
+ # puts result.enhanced # polished, <=160-char rewrite
124
+ # puts result.explanation # what changed and why
125
+ def enhance(text: nil, message_type: nil)
126
+ if (text.nil? || text.to_s.empty?) && (message_type.nil? || message_type.to_s.empty?)
127
+ raise ValidationError, "Provide 'text' or 'message_type'"
128
+ end
129
+
130
+ body = {}
131
+ body[:text] = text unless text.nil?
132
+ body[:messageType] = message_type if message_type
133
+
134
+ response = client.post("/ai/enhance", body)
135
+ EnhancedMessage.new(response)
136
+ end
137
+
53
138
  # List messages
54
139
  #
55
140
  # @param limit [Integer] Maximum messages to return (default: 20, max: 100)
@@ -42,7 +42,18 @@ module Sendly
42
42
  # A number owned by the account.
43
43
  class PhoneNumber
44
44
  attr_reader :id, :phone_number, :status, :source, :country_code,
45
- :phone_number_type, :monthly_cost_cents
45
+ :phone_number_type, :monthly_cost_cents,
46
+ # true when this is the workspace's default sender. Only present
47
+ # on single-number responses ({NumbersResource#get} /
48
+ # {NumbersResource#update}); nil in the {NumbersResource#list} projection.
49
+ :is_default,
50
+ # ISO-8601 timestamp string, or nil when the number still needs
51
+ # regulatory documents (a value means docs are under carrier review).
52
+ :requirements_submitted_at,
53
+ # true when the number is scheduled for release at period end.
54
+ :pending_cancellation,
55
+ # ISO-8601 timestamp string, or nil when no release is scheduled.
56
+ :scheduled_release_at
46
57
 
47
58
  def initialize(data)
48
59
  @id = data["id"]
@@ -52,13 +63,20 @@ module Sendly
52
63
  @country_code = data["countryCode"] || data["country_code"]
53
64
  @phone_number_type = data["phoneNumberType"] || data["phone_number_type"]
54
65
  @monthly_cost_cents = data["monthlyCostCents"] || data["monthly_cost_cents"]
66
+ @is_default = data.key?("isDefault") ? data["isDefault"] : data["is_default"]
67
+ @requirements_submitted_at = data["requirementsSubmittedAt"] || data["requirements_submitted_at"]
68
+ @pending_cancellation = data.key?("pendingCancellation") ? data["pendingCancellation"] : data["pending_cancellation"]
69
+ @scheduled_release_at = data["scheduledReleaseAt"] || data["scheduled_release_at"]
55
70
  end
56
71
 
57
72
  def to_h
58
73
  {
59
74
  id: id, phone_number: phone_number, status: status, source: source,
60
75
  country_code: country_code, phone_number_type: phone_number_type,
61
- monthly_cost_cents: monthly_cost_cents
76
+ monthly_cost_cents: monthly_cost_cents, is_default: is_default,
77
+ requirements_submitted_at: requirements_submitted_at,
78
+ pending_cancellation: pending_cancellation,
79
+ scheduled_release_at: scheduled_release_at
62
80
  }.compact
63
81
  end
64
82
  end
@@ -147,6 +165,45 @@ module Sendly
147
165
  end
148
166
  end
149
167
 
168
+ # The result of releasing an owned number via {NumbersResource#release}.
169
+ #
170
+ # A live paid purchase is cancelled at the end of the paid period — the result
171
+ # then reports +scheduled?+ true with a +scheduled_release_at+ timestamp.
172
+ # Everything else is released immediately (+scheduled?+ false, no timestamp).
173
+ # The raw parsed response is preserved on +#raw+.
174
+ class NumberRelease
175
+ # @return [Boolean] true on success
176
+ attr_reader :success
177
+
178
+ # @return [Boolean] true when the release was scheduled for the period end
179
+ attr_reader :scheduled
180
+
181
+ # @return [String, nil] ISO-8601 timestamp the scheduled release takes effect
182
+ attr_reader :scheduled_release_at
183
+
184
+ # @return [Hash] The raw parsed response
185
+ attr_reader :raw
186
+
187
+ def initialize(data)
188
+ @raw = data
189
+ @success = data.key?("success") ? data["success"] : true
190
+ @scheduled = data["scheduled"] || false
191
+ @scheduled_release_at = data["scheduledReleaseAt"] || data["scheduled_release_at"]
192
+ end
193
+
194
+ # @return [Boolean] Whether the release was scheduled (vs immediate)
195
+ def scheduled?
196
+ scheduled
197
+ end
198
+
199
+ def to_h
200
+ {
201
+ success: success, scheduled: scheduled,
202
+ scheduled_release_at: scheduled_release_at
203
+ }.compact
204
+ end
205
+ end
206
+
150
207
  # Numbers resource — search, list, and purchase phone numbers.
151
208
  #
152
209
  # @example List supported countries
@@ -244,5 +301,67 @@ module Sendly
244
301
  response = @client.post("/numbers/buy", body)
245
302
  NumberPurchase.new(response)
246
303
  end
304
+
305
+ # Get a single number owned by the account.
306
+ #
307
+ # Unlike the entries {#list} returns, this includes +is_default+ — whether
308
+ # the number is the workspace's default sender.
309
+ #
310
+ # @param id [String] The number's id
311
+ # @return [PhoneNumber]
312
+ # @raise [Sendly::NotFoundError] If no such number exists in your workspace
313
+ def get(id)
314
+ raise ValidationError, "id is required" if id.nil? || id.to_s.empty?
315
+
316
+ encoded_id = URI.encode_www_form_component(id)
317
+ response = @client.get("/numbers/#{encoded_id}")
318
+ PhoneNumber.new(response)
319
+ end
320
+
321
+ # Update a number you own. Supply at least one supported mutation — these are
322
+ # the only two the API supports:
323
+ #
324
+ # - +is_default: true+ — make this the workspace's default sending number
325
+ # (the number must be +active+).
326
+ # - +pending_cancellation: false+ — cancel a previously scheduled release and
327
+ # keep the number.
328
+ #
329
+ # @param id [String] The number's id
330
+ # @param is_default [Boolean, nil] Pass +true+ to make this the default sender
331
+ # @param pending_cancellation [Boolean, nil] Pass +false+ to cancel a scheduled release
332
+ # @return [PhoneNumber] The updated number (including +is_default+)
333
+ # @raise [Sendly::ValidationError] If no supported field is given, or +is_default+
334
+ # is requested for a non-active number
335
+ # @raise [Sendly::NotFoundError] If no such number exists in your workspace
336
+ def update(id, is_default: nil, pending_cancellation: nil)
337
+ raise ValidationError, "id is required" if id.nil? || id.to_s.empty?
338
+ if is_default.nil? && pending_cancellation.nil?
339
+ raise ValidationError, "Provide is_default: true and/or pending_cancellation: false"
340
+ end
341
+
342
+ body = {}
343
+ body[:isDefault] = is_default unless is_default.nil?
344
+ body[:pendingCancellation] = pending_cancellation unless pending_cancellation.nil?
345
+
346
+ encoded_id = URI.encode_www_form_component(id)
347
+ response = @client.patch("/numbers/#{encoded_id}", body)
348
+ PhoneNumber.new(response)
349
+ end
350
+
351
+ # Release a number you own. A live paid purchase is cancelled at the end of
352
+ # the paid period (the result then reports +scheduled?+ true with a
353
+ # +scheduled_release_at+); everything else is released immediately.
354
+ #
355
+ # @param id [String] The number's id
356
+ # @return [NumberRelease]
357
+ # @raise [Sendly::NotFoundError] If no such number exists in your workspace
358
+ # @raise [Sendly::ValidationError] If the carrier release failed
359
+ def release(id)
360
+ raise ValidationError, "id is required" if id.nil? || id.to_s.empty?
361
+
362
+ encoded_id = URI.encode_www_form_component(id)
363
+ response = @client.delete("/numbers/#{encoded_id}")
364
+ NumberRelease.new(response)
365
+ end
247
366
  end
248
367
  end