sendly 3.33.0 → 3.35.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: d643b13d3d51b1ce011b394a31a7fb722c26110e12a87d717e407709bdfd687b
4
- data.tar.gz: e17abd7c0fa512116b06fb02927e9f8b3c41d2e082f214b48657b15fb0e327e3
3
+ metadata.gz: '09a32581d4ce1256ffd2bf348216cf42877665720fd31f7eab4ff50ba9eb30be'
4
+ data.tar.gz: f713cd2607feeeab90f997a66580d888c2da4bc7b6cf427c4fd77b27dadd866b
5
5
  SHA512:
6
- metadata.gz: 9a5808b5f638300369e98c7746a049cb5633e727f70648e16f40486564bdb5920731feb3a7a97ebec1580f92bff8ceddca4c8795a224e0ce1c1873f51d9dd3cc
7
- data.tar.gz: 1523973847432c7aa50947f6bf46763987d8be1130df68c318fdae466680d18477dece25cfb56200d4f4120cd772f157046b3410cb3284355634bd851efeda3e
6
+ metadata.gz: 4c0e5cf78135e5e1571d724084aeb850928fdf96fb1bdb9ada800835574816ea54b5bb5cca17298c3aac26d456b0704cce9a3a177e797f800af65c538533a5ff
7
+ data.tar.gz: 9ad05417089aeb02dff49dc4e33a66484cf6cc1ffd03aefa35e21b7d7b7d9745de7bc7ddbcf2330bc7d57a625e208c0778ce37fe0efd410536391fe2ef178d71
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # sendly (Ruby)
2
2
 
3
+ ## 3.33.0
4
+
5
+ ### Minor Changes
6
+
7
+ - New `client.conversations.suggest_replies(id)` method — `POST /api/v1/conversations/:id/suggest-replies`. Returns AI-generated reply suggestions for a conversation based on its recent message history, mirroring the Node SDK's `conversations.suggestReplies()` and the equivalent methods on the other Sendly SDKs (closes a feature parity gap). Returns a `Sendly::SuggestRepliesResponse`, which is `Enumerable` over its `SuggestedReply` entries and also exposes `#suggestions`, `#based_on_message_id`, and `#model`.
8
+
9
+ ```ruby
10
+ client = Sendly::Client.new("sk_live_v1_xxx")
11
+
12
+ result = client.conversations.suggest_replies("conv_abc123")
13
+ result.suggestions.each do |reply|
14
+ puts "[#{reply.tone}] #{reply.text}"
15
+ end
16
+ ```
17
+
3
18
  ## 3.32.0
4
19
 
5
20
  ### Minor Changes
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sendly (3.33.0)
4
+ sendly (3.35.0)
5
5
  faraday (~> 2.0)
6
6
  faraday-retry (~> 2.0)
7
7
 
data/README.md CHANGED
@@ -291,6 +291,47 @@ puts "New key: #{result['key']}" # Only shown once!
291
291
  client.account.revoke_api_key('key_xxx')
292
292
  ```
293
293
 
294
+ ## Numbers
295
+
296
+ Search for, list, and purchase phone numbers. Requires an API key with the
297
+ `numbers:read` / `numbers:write` scopes.
298
+
299
+ ```ruby
300
+ # List supported countries and the number types available in each
301
+ client.numbers.list_countries[:countries].each do |country|
302
+ puts "#{country.code} #{country.name}: #{country.number_types.join(', ')}"
303
+ end
304
+
305
+ # Find available numbers (monthly_cost is already customer-priced)
306
+ result = client.numbers.list_available(country: 'GB', type: 'mobile', contains: '777')
307
+ number = result[:numbers].first
308
+ puts "#{number.phone_number} — #{number.monthly_cost} #{number.currency}/mo"
309
+
310
+ # List numbers you already own
311
+ client.numbers.list[:numbers].each do |n|
312
+ puts "#{n.phone_number} (#{n.status})"
313
+ end
314
+
315
+ # Buy a number
316
+ purchase = client.numbers.buy(
317
+ phone_number: number.phone_number,
318
+ country_code: number.country,
319
+ phone_number_type: number.number_type,
320
+ monthly_cost: number.monthly_cost
321
+ )
322
+
323
+ case purchase.status
324
+ when 'provisioning'
325
+ puts "Provisioning #{purchase.number.phone_number}"
326
+ when 'documents_required', 'payment_required'
327
+ # Hand the user the hosted page + code, wait for them to finish, then
328
+ # re-call buy with the SAME arguments plus the completed action's code.
329
+ puts "Visit #{purchase.action_url} and enter code #{purchase.action_code}"
330
+ # ...after the action completes:
331
+ # client.numbers.buy(..., action_code: purchase.action_code)
332
+ end
333
+ ```
334
+
294
335
  ## Error Handling
295
336
 
296
337
  ```ruby
data/lib/sendly/client.rb CHANGED
@@ -157,6 +157,13 @@ module Sendly
157
157
  @business_upgrade ||= BusinessUpgradeResource.new(self)
158
158
  end
159
159
 
160
+ # Access the Numbers resource
161
+ #
162
+ # @return [Sendly::NumbersResource]
163
+ def numbers
164
+ @numbers ||= NumbersResource.new(self)
165
+ end
166
+
160
167
  # Make a GET request
161
168
  #
162
169
  # @param path [String] API path
@@ -107,6 +107,14 @@ module Sendly
107
107
  ConversationContext.new(response)
108
108
  end
109
109
 
110
+ def suggest_replies(id)
111
+ raise ValidationError, "Conversation ID is required" if id.nil? || id.empty?
112
+
113
+ encoded_id = URI.encode_www_form_component(id)
114
+ response = @client.post("/conversations/#{encoded_id}/suggest-replies")
115
+ SuggestRepliesResponse.new(response)
116
+ end
117
+
110
118
  def each(status: nil, batch_size: 100, &block)
111
119
  return enum_for(:each, status: status, batch_size: batch_size) unless block_given?
112
120
 
@@ -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
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendly
4
+ # A country in which numbers can be searched and purchased, along with the
5
+ # number types available there (e.g. "mobile", "local", "toll_free").
6
+ class NumberCountry
7
+ attr_reader :code, :name, :number_types
8
+
9
+ def initialize(data)
10
+ @code = data["code"]
11
+ @name = data["name"]
12
+ @number_types = data["numberTypes"] || data["number_types"] || []
13
+ end
14
+
15
+ def to_h
16
+ { code: code, name: name, number_types: number_types }.compact
17
+ end
18
+ end
19
+
20
+ # A number that is available to purchase. The monthly cost is already
21
+ # customer-priced and returned as a string in the given currency.
22
+ class AvailableNumber
23
+ attr_reader :phone_number, :country, :number_type, :monthly_cost, :currency
24
+
25
+ def initialize(data)
26
+ @phone_number = data["phoneNumber"] || data["phone_number"]
27
+ @country = data["country"]
28
+ @number_type = data["numberType"] || data["number_type"]
29
+ @monthly_cost = data["monthlyCost"] || data["monthly_cost"]
30
+ @currency = data["currency"]
31
+ end
32
+
33
+ def to_h
34
+ {
35
+ phone_number: phone_number, country: country,
36
+ number_type: number_type, monthly_cost: monthly_cost,
37
+ currency: currency
38
+ }.compact
39
+ end
40
+ end
41
+
42
+ # A number owned by the account.
43
+ class PhoneNumber
44
+ attr_reader :id, :phone_number, :status, :source, :country_code,
45
+ :phone_number_type, :monthly_cost_cents,
46
+ # ISO-8601 timestamp string, or nil when the number still needs
47
+ # regulatory documents (a value means docs are under carrier review).
48
+ :requirements_submitted_at,
49
+ # true when the number is scheduled for release at period end.
50
+ :pending_cancellation,
51
+ # ISO-8601 timestamp string, or nil when no release is scheduled.
52
+ :scheduled_release_at
53
+
54
+ def initialize(data)
55
+ @id = data["id"]
56
+ @phone_number = data["phoneNumber"] || data["phone_number"]
57
+ @status = data["status"]
58
+ @source = data["source"]
59
+ @country_code = data["countryCode"] || data["country_code"]
60
+ @phone_number_type = data["phoneNumberType"] || data["phone_number_type"]
61
+ @monthly_cost_cents = data["monthlyCostCents"] || data["monthly_cost_cents"]
62
+ @requirements_submitted_at = data["requirementsSubmittedAt"] || data["requirements_submitted_at"]
63
+ @pending_cancellation = data.key?("pendingCancellation") ? data["pendingCancellation"] : data["pending_cancellation"]
64
+ @scheduled_release_at = data["scheduledReleaseAt"] || data["scheduled_release_at"]
65
+ end
66
+
67
+ def to_h
68
+ {
69
+ id: id, phone_number: phone_number, status: status, source: source,
70
+ country_code: country_code, phone_number_type: phone_number_type,
71
+ monthly_cost_cents: monthly_cost_cents,
72
+ requirements_submitted_at: requirements_submitted_at,
73
+ pending_cancellation: pending_cancellation,
74
+ scheduled_release_at: scheduled_release_at
75
+ }.compact
76
+ end
77
+ end
78
+
79
+ # The result of a buy request. The API responds 202 with one of three
80
+ # statuses:
81
+ #
82
+ # - +"provisioning"+: the purchase succeeded and the number is being
83
+ # provisioned. +number+ carries the new {PhoneNumber}.
84
+ # - +"documents_required"+ / +"payment_required"+: the purchase is paused
85
+ # pending a hosted Sendly step. +action+ carries the hand-off object,
86
+ # which holds TWO distinct identifiers:
87
+ # - +actionCode+ — a 32-hex action identifier (read via {#action_identifier}).
88
+ # Use THIS to poll the action and to re-call +buy+ (pass it as the
89
+ # +action_code:+ argument).
90
+ # - +code+ — a short user code (read via {#action_code}). DISPLAY ONLY:
91
+ # show it to the human to type on the hosted page to prove terminal
92
+ # access. Never pass it back as +action_code:+.
93
+ # Hand the user +action_url+ + {#action_code}, wait for completion, then
94
+ # re-call +buy+ with the same body plus +action_code:+ set to
95
+ # {#action_identifier}. +requirements+ describes what's missing (a JSON
96
+ # array).
97
+ #
98
+ # The raw parsed response is preserved verbatim on +#raw+ so callers can
99
+ # read any field the server adds.
100
+ class NumberPurchase
101
+ attr_reader :status, :number, :requirements, :action, :raw
102
+
103
+ def initialize(data)
104
+ @raw = data
105
+ @status = data["status"]
106
+ @number = data["number"] ? PhoneNumber.new(data["number"]) : nil
107
+ @requirements = data["requirements"]
108
+ @action = data["action"]
109
+ end
110
+
111
+ def provisioning?
112
+ status == "provisioning"
113
+ end
114
+
115
+ def documents_required?
116
+ status == "documents_required"
117
+ end
118
+
119
+ def payment_required?
120
+ status == "payment_required"
121
+ end
122
+
123
+ # @return [String, nil] The hosted Sendly page URL the user must visit.
124
+ def action_url
125
+ action && (action["url"] || action[:url])
126
+ end
127
+
128
+ # The 32-hex action identifier. Use THIS to poll the action's status and
129
+ # to re-call +buy+ (pass it as the +action_code:+ argument). NOT for
130
+ # display.
131
+ #
132
+ # @return [String, nil]
133
+ def action_identifier
134
+ action && (action["actionCode"] || action["action_code"] || action[:actionCode] || action[:action_code])
135
+ end
136
+
137
+ # The short user code shown to the human to type on the hosted page to
138
+ # prove terminal access. DISPLAY ONLY — to re-buy/poll, use
139
+ # {#action_identifier}, not this.
140
+ #
141
+ # @return [String, nil]
142
+ def action_code
143
+ action && (action["code"] || action[:code])
144
+ end
145
+
146
+ # Expiry of the action, as an epoch-milliseconds number (the server sends
147
+ # a number, not an ISO-8601 string). Older payloads may carry it under
148
+ # +expires_at+; both are accepted.
149
+ #
150
+ # @return [Integer, String, nil]
151
+ def action_expires_at
152
+ action && (action["expiresAt"] || action["expires_at"] || action[:expiresAt] || action[:expires_at])
153
+ end
154
+
155
+ def to_h
156
+ {
157
+ status: status, number: number&.to_h,
158
+ requirements: requirements, action: action
159
+ }.compact
160
+ end
161
+ end
162
+
163
+ # Numbers resource — search, list, and purchase phone numbers.
164
+ #
165
+ # @example List supported countries
166
+ # result = client.numbers.list_countries
167
+ # result[:countries].each { |c| puts "#{c.code} #{c.name}" }
168
+ #
169
+ # @example Find available mobile numbers in the UK
170
+ # result = client.numbers.list_available(country: "GB", type: "mobile")
171
+ # number = result[:numbers].first
172
+ #
173
+ # @example Buy a number (may pause for a hosted step)
174
+ # purchase = client.numbers.buy(
175
+ # phone_number: number.phone_number,
176
+ # country_code: number.country,
177
+ # phone_number_type: number.number_type,
178
+ # monthly_cost: number.monthly_cost
179
+ # )
180
+ # if purchase.documents_required? || purchase.payment_required?
181
+ # # Show the user the URL + display code; keep the 32-hex identifier for re-buy
182
+ # puts "Visit #{purchase.action_url} and enter code #{purchase.action_code}"
183
+ # # ...once they finish, re-call buy with action_code: purchase.action_identifier
184
+ # end
185
+ class NumbersResource
186
+ def initialize(client)
187
+ @client = client
188
+ end
189
+
190
+ # List the countries in which numbers can be searched and purchased,
191
+ # along with the number types available in each.
192
+ #
193
+ # @return [Hash] +{ countries: Array<NumberCountry> }+
194
+ def list_countries
195
+ response = @client.get("/numbers/countries")
196
+ countries = (response["countries"] || []).map { |c| NumberCountry.new(c) }
197
+ { countries: countries }
198
+ end
199
+
200
+ # Search for numbers available to purchase in a country.
201
+ #
202
+ # @param country [String] ISO country code (e.g. "GB")
203
+ # @param type [String] Number type (e.g. "mobile", "local", "toll_free")
204
+ # @param contains [String, nil] Optional digit/letter filter
205
+ # @return [Hash] +{ numbers: Array<AvailableNumber> }+
206
+ def list_available(country:, type:, contains: nil)
207
+ raise ValidationError, "country is required" if country.nil? || country.to_s.empty?
208
+ raise ValidationError, "type is required" if type.nil? || type.to_s.empty?
209
+
210
+ params = { country: country, type: type }
211
+ params[:contains] = contains if contains
212
+
213
+ response = @client.get("/numbers/available", params)
214
+ numbers = (response["numbers"] || []).map { |n| AvailableNumber.new(n) }
215
+ { numbers: numbers }
216
+ end
217
+
218
+ # List the numbers owned by the account.
219
+ #
220
+ # @return [Hash] +{ numbers: Array<PhoneNumber> }+
221
+ def list
222
+ response = @client.get("/numbers")
223
+ numbers = (response["numbers"] || []).map { |n| PhoneNumber.new(n) }
224
+ { numbers: numbers }
225
+ end
226
+
227
+ # Buy a number.
228
+ #
229
+ # Returns a {NumberPurchase}. When its status is +documents_required+ or
230
+ # +payment_required+, hand the user +purchase.action_url+ +
231
+ # +purchase.action_code+ (the short display code), wait for that hosted
232
+ # step to complete, then re-call +buy+ with the SAME arguments plus
233
+ # +action_code:+ set to +purchase.action_identifier+ (the 32-hex action
234
+ # identifier) — NOT the display code.
235
+ #
236
+ # @param phone_number [String]
237
+ # @param country_code [String]
238
+ # @param phone_number_type [String]
239
+ # @param monthly_cost [String] Customer-priced monthly cost (as returned by {#list_available})
240
+ # @param action_code [String, nil] The 32-hex action identifier of a
241
+ # completed hosted action (see {NumberPurchase#action_identifier}), on re-call
242
+ # @return [NumberPurchase]
243
+ def buy(phone_number:, country_code:, phone_number_type:, monthly_cost:, action_code: nil)
244
+ raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
245
+ raise ValidationError, "country_code is required" if country_code.nil? || country_code.to_s.empty?
246
+ raise ValidationError, "phone_number_type is required" if phone_number_type.nil? || phone_number_type.to_s.empty?
247
+ raise ValidationError, "monthly_cost is required" if monthly_cost.nil? || monthly_cost.to_s.empty?
248
+
249
+ body = {
250
+ phoneNumber: phone_number,
251
+ countryCode: country_code,
252
+ phoneNumberType: phone_number_type,
253
+ monthlyCost: monthly_cost
254
+ }
255
+ body[:actionCode] = action_code if action_code
256
+
257
+ response = @client.post("/numbers/buy", body)
258
+ NumberPurchase.new(response)
259
+ end
260
+ end
261
+ end
data/lib/sendly/types.rb CHANGED
@@ -596,6 +596,75 @@ module Sendly
596
596
  end
597
597
  end
598
598
 
599
+ # ============================================================================
600
+ # Suggested Replies
601
+ # ============================================================================
602
+
603
+ class SuggestedReply
604
+ # @return [String] Suggested reply text
605
+ attr_reader :text
606
+
607
+ # @return [String] Tone of the suggestion (professional, friendly, concise)
608
+ attr_reader :tone
609
+
610
+ TONES = %w[professional friendly concise].freeze
611
+
612
+ def initialize(data)
613
+ @text = data["text"]
614
+ @tone = data["tone"]
615
+ end
616
+
617
+ def to_h
618
+ { text: text, tone: tone }.compact
619
+ end
620
+ end
621
+
622
+ class SuggestRepliesResponse
623
+ include Enumerable
624
+
625
+ # @return [Array<SuggestedReply>] AI-generated reply suggestions
626
+ attr_reader :suggestions
627
+
628
+ # @return [String, nil] ID of the inbound message the suggestions are based on
629
+ attr_reader :based_on_message_id
630
+
631
+ # @return [String, nil] Model that generated the suggestions
632
+ attr_reader :model
633
+
634
+ def initialize(data)
635
+ @suggestions = (data["suggestions"] || []).map { |s| SuggestedReply.new(s) }
636
+ @based_on_message_id = data["basedOnMessageId"] || data["based_on_message_id"]
637
+ @model = data["model"]
638
+ end
639
+
640
+ def each(&block)
641
+ suggestions.each(&block)
642
+ end
643
+
644
+ def count
645
+ suggestions.length
646
+ end
647
+
648
+ alias size count
649
+ alias length count
650
+
651
+ def empty?
652
+ suggestions.empty?
653
+ end
654
+
655
+ def first
656
+ suggestions.first
657
+ end
658
+
659
+ def to_h
660
+ {
661
+ suggestions: suggestions.map(&:to_h),
662
+ based_on_message_id: based_on_message_id,
663
+ model: model
664
+ }.compact
665
+ end
666
+ end
667
+
599
668
  # ============================================================================
600
669
  # Labels
601
670
  # ============================================================================
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sendly
4
- VERSION = "3.33.0"
4
+ VERSION = "3.35.0"
5
5
  end
data/lib/sendly.rb CHANGED
@@ -22,6 +22,7 @@ require_relative "sendly/drafts_resource"
22
22
  require_relative "sendly/rules_resource"
23
23
  require_relative "sendly/enterprise"
24
24
  require_relative "sendly/business_upgrade_resource"
25
+ require_relative "sendly/numbers_resource"
25
26
 
26
27
  # Sendly Ruby SDK
27
28
  #
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.33.0
4
+ version: 3.35.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-05-26 00:00:00.000000000 Z
11
+ date: 2026-06-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -136,6 +136,7 @@ files:
136
136
  - lib/sendly/labels_resource.rb
137
137
  - lib/sendly/media.rb
138
138
  - lib/sendly/messages.rb
139
+ - lib/sendly/numbers_resource.rb
139
140
  - lib/sendly/rules_resource.rb
140
141
  - lib/sendly/templates_resource.rb
141
142
  - lib/sendly/types.rb