sendly 3.31.0 → 3.34.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: 80748f70518bf065b5c3aec3495bbc69ef4641793a57186757ea1f10957de933
4
- data.tar.gz: b98259199daccc2948950da3a6058b4b66140ff72b4c8a9b1ad5fae6a59a639c
3
+ metadata.gz: 71c3ad356b4aa2903e05f1f174ce9a70f1fb1fe5cc4b0748ae69db333a0c25ac
4
+ data.tar.gz: 86df6410241d17dc84854057d19b2b13e6f32f504bf87243e276ec9260f78a34
5
5
  SHA512:
6
- metadata.gz: 796976f6e39f8bb3fad45e86b9bdb5256521c46dbe566ace7c14ccb72192ea4902c79965b93cdf757eb23677e254166cbdc0ba2195ef51c0c90d85c7365984c6
7
- data.tar.gz: 32b41bc79313a7f26d6cd6dac95125ad210168be1dcd8ca9e2389a7d1beb2c4678fa7f32769e4263c102cdf1b94ed68276bb8c84b84d5dc67c992c9238a1f230
6
+ metadata.gz: 385c9b3f1973a51b923fed18d0b23a128c060d0d122ed782e08f3a21c1477ece2caca8aaeba65c71c8c494946630ffe21e9ac12eade63530ea207de10dfa0661
7
+ data.tar.gz: 6e274e7b40280d52118812b61ffa31b936d912baa0147027a448c7156d110ed24e4c3c0ba70f2a589bae3d0b3531316761a80fe2864fa821415002e0d3741f01
data/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
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
+
18
+ ## 3.32.0
19
+
20
+ ### Minor Changes
21
+
22
+ - New `business_upgrade` resource for the toll-free entity-upgrade ("fork-with-new-number") flow. When a customer forms a new legal entity (e.g. an LLC), this resource lets them reserve a new toll-free number under the new entity, submit it for carrier review, and atomically swap to it on approval — without disrupting outbound SMS during the 1-2 week review window. Mirrors the Node SDK's `businessUpgrade` resource at parity.
23
+
24
+ ```ruby
25
+ client = Sendly::Client.new("sk_live_v1_xxx")
26
+
27
+ # Validate before submitting (no writes)
28
+ preview = client.business_upgrade.preflight(
29
+ business_name: "Acme Holdings LLC",
30
+ brn: "12-3456789",
31
+ brn_type: "EIN",
32
+ brn_country: "US",
33
+ entity_type: "PRIVATE_PROFIT"
34
+ )
35
+
36
+ # Best-of prefill across all the caller's verified workspaces
37
+ prefill = client.business_upgrade.best_prefill
38
+
39
+ # Submit the upgrade with the IRS letter (multipart upload)
40
+ result = client.business_upgrade.start(
41
+ "ws_abc",
42
+ business_name: "Acme Holdings LLC",
43
+ brn: "12-3456789",
44
+ brn_type: "EIN",
45
+ brn_country: "US",
46
+ entity_type: "PRIVATE_PROFIT",
47
+ ein_doc_path: "./CP-575.pdf"
48
+ )
49
+
50
+ # Status, cancel, resubmit, set old-number disposition
51
+ client.business_upgrade.status("ws_abc")
52
+ client.business_upgrade.cancel("ws_abc")
53
+ client.business_upgrade.resubmit("ws_abc", contact_email: "new@acme.com")
54
+ client.business_upgrade.set_disposition("ws_abc", disposition: "released")
55
+ client.business_upgrade.set_disposition("ws_abc", disposition: "moved", target_workspace_id: "ws_xyz")
56
+ ```
57
+
58
+ Methods: `preflight`, `best_prefill`, `start`, `status`, `cancel`, `resubmit`, `set_disposition`. EIN PDFs can be passed via `ein_doc_path:` (file path) or `ein_doc:` (raw bytes / IO).
59
+
3
60
  ## 3.31.0
4
61
 
5
62
  ### Patch Changes
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sendly (3.31.0)
4
+ sendly (3.34.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
@@ -0,0 +1,404 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "securerandom"
7
+
8
+ module Sendly
9
+ # Business Upgrade resource — Entity-Upgrade ("fork-with-new-number")
10
+ #
11
+ # Manages the toll-free business entity upgrade flow: when a customer
12
+ # forms a new legal entity (e.g. an LLC), this resource lets them
13
+ # reserve a new toll-free number under the new entity, submit it for
14
+ # carrier review, and atomically swap to it on approval — without
15
+ # disrupting outbound SMS during the 1-2 week review window.
16
+ #
17
+ # @see https://sendly.live/docs/business-upgrade
18
+ #
19
+ # @example Validate before submitting
20
+ # preview = client.business_upgrade.preflight(
21
+ # business_name: "Acme Holdings LLC",
22
+ # brn: "12-3456789",
23
+ # brn_type: "EIN",
24
+ # brn_country: "US",
25
+ # entity_type: "PRIVATE_PROFIT"
26
+ # )
27
+ #
28
+ # @example Submit an upgrade with the IRS letter
29
+ # result = client.business_upgrade.start(
30
+ # "ws_abc",
31
+ # business_name: "Acme Holdings LLC",
32
+ # brn: "12-3456789",
33
+ # brn_type: "EIN",
34
+ # brn_country: "US",
35
+ # entity_type: "PRIVATE_PROFIT",
36
+ # ein_doc_path: "./CP-575.pdf"
37
+ # )
38
+ class BusinessUpgradeResource
39
+ # Entity types accepted by the carrier
40
+ ENTITY_TYPES = %w[
41
+ SOLE_PROPRIETOR
42
+ PRIVATE_PROFIT
43
+ PUBLIC_PROFIT
44
+ NON_PROFIT
45
+ GOVERNMENT
46
+ ].freeze
47
+
48
+ # Business Registration Number types
49
+ BRN_TYPES = %w[EIN SSN DUNS CRA VAT LEI OTHER].freeze
50
+
51
+ # Disposition options for the old toll-free number after approval
52
+ DISPOSITIONS = %w[moved released].freeze
53
+
54
+ def initialize(client)
55
+ @client = client
56
+ end
57
+
58
+ # Validate a candidate entity upgrade payload before submission.
59
+ # Returns issues + proposed auto-fixes. No writes — purely advisory.
60
+ #
61
+ # Accepts the same fields as +start+. Returns a hash with
62
+ # +verdict+ (+"ready"+, +"warnings"+, +"blocked"+), +issues+, and
63
+ # +proposedFixes+.
64
+ #
65
+ # @param business_name [String, nil]
66
+ # @param brn [String, nil]
67
+ # @param brn_type [String, nil] One of {BRN_TYPES}
68
+ # @param brn_country [String, nil] ISO country code
69
+ # @param entity_type [String, nil] One of {ENTITY_TYPES}
70
+ # @param doing_business_as [String, nil]
71
+ # @param website [String, nil]
72
+ # @param address1 [String, nil]
73
+ # @param address2 [String, nil]
74
+ # @param city [String, nil]
75
+ # @param state [String, nil]
76
+ # @param zip [String, nil]
77
+ # @param address_country [String, nil]
78
+ # @param contact_first_name [String, nil]
79
+ # @param contact_last_name [String, nil]
80
+ # @param contact_email [String, nil]
81
+ # @param contact_phone [String, nil]
82
+ # @param monthly_volume [String, nil]
83
+ # @param use_case [String, nil]
84
+ # @param use_case_summary [String, nil]
85
+ # @param sample_messages [String, nil]
86
+ # @param opt_in_workflow [String, nil]
87
+ # @param privacy_url [String, nil]
88
+ # @param terms_url [String, nil]
89
+ # @param additional_information [String, nil]
90
+ # @param age_gated_content [Boolean, nil]
91
+ # @return [Hash] Preflight report
92
+ def preflight(business_name: nil, brn: nil, brn_type: nil, brn_country: nil,
93
+ entity_type: nil, doing_business_as: nil, website: nil,
94
+ address1: nil, address2: nil, city: nil, state: nil, zip: nil,
95
+ address_country: nil, contact_first_name: nil,
96
+ contact_last_name: nil, contact_email: nil, contact_phone: nil,
97
+ monthly_volume: nil, use_case: nil, use_case_summary: nil,
98
+ sample_messages: nil, opt_in_workflow: nil, privacy_url: nil,
99
+ terms_url: nil, additional_information: nil,
100
+ age_gated_content: nil)
101
+ body = build_upgrade_body(
102
+ business_name: business_name, brn: brn, brn_type: brn_type,
103
+ brn_country: brn_country, entity_type: entity_type,
104
+ doing_business_as: doing_business_as, website: website,
105
+ address1: address1, address2: address2, city: city, state: state,
106
+ zip: zip, address_country: address_country,
107
+ contact_first_name: contact_first_name,
108
+ contact_last_name: contact_last_name, contact_email: contact_email,
109
+ contact_phone: contact_phone, monthly_volume: monthly_volume,
110
+ use_case: use_case, use_case_summary: use_case_summary,
111
+ sample_messages: sample_messages, opt_in_workflow: opt_in_workflow,
112
+ privacy_url: privacy_url, terms_url: terms_url,
113
+ additional_information: additional_information,
114
+ age_gated_content: age_gated_content
115
+ )
116
+
117
+ @client.post("/verification/preflight", body)
118
+ end
119
+
120
+ # Get a "best-of" prefill across all the caller's verified workspaces.
121
+ # Returns most-recent non-empty values per messaging field. Use this
122
+ # to pre-populate the upgrade form for users whose current workspace
123
+ # has incomplete data.
124
+ #
125
+ # @return [Hash] +{ "prefill" => {...}, "sourceWorkspaceCount" => Integer }+
126
+ def best_prefill
127
+ @client.get("/verification/best-prefill")
128
+ end
129
+
130
+ # Start an entity upgrade for the given workspace. Auto-provisions
131
+ # a new toll-free number + messaging profile and submits to the
132
+ # carrier for review. Returns the pending verification details.
133
+ #
134
+ # The current toll-free number continues sending throughout the
135
+ # 1-2 week carrier review; on approval, an atomic swap promotes
136
+ # the new number.
137
+ #
138
+ # The EIN document (when supplied) is uploaded as multipart form-data
139
+ # under the +einDoc+ field. Provide either +ein_doc_path+ (a path on
140
+ # disk) or +ein_doc+ (raw bytes / IO) — not both.
141
+ #
142
+ # @param workspace_id [String]
143
+ # @param business_name [String]
144
+ # @param brn [String]
145
+ # @param brn_type [String] One of {BRN_TYPES}
146
+ # @param brn_country [String] ISO country code
147
+ # @param entity_type [String] One of {ENTITY_TYPES}
148
+ # @param ein_doc_path [String, nil] Path to the EIN/CP-575 PDF
149
+ # @param ein_doc [String, IO, nil] Raw EIN PDF bytes or IO
150
+ # @param ein_doc_filename [String, nil] Filename (defaults to "ein-doc.pdf")
151
+ # @param ein_doc_content_type [String, nil] MIME type (defaults to "application/pdf")
152
+ # @return [Hash] Upgrade start response
153
+ def start(workspace_id, business_name:, brn:, brn_type:, brn_country:,
154
+ entity_type:, doing_business_as: nil, website: nil,
155
+ address1: nil, address2: nil, city: nil, state: nil, zip: nil,
156
+ address_country: nil, contact_first_name: nil,
157
+ contact_last_name: nil, contact_email: nil, contact_phone: nil,
158
+ monthly_volume: nil, use_case: nil, use_case_summary: nil,
159
+ sample_messages: nil, opt_in_workflow: nil, privacy_url: nil,
160
+ terms_url: nil, additional_information: nil,
161
+ age_gated_content: nil, ein_doc_path: nil, ein_doc: nil,
162
+ ein_doc_filename: nil, ein_doc_content_type: nil)
163
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
164
+
165
+ fields = build_upgrade_body(
166
+ business_name: business_name, brn: brn, brn_type: brn_type,
167
+ brn_country: brn_country, entity_type: entity_type,
168
+ doing_business_as: doing_business_as, website: website,
169
+ address1: address1, address2: address2, city: city, state: state,
170
+ zip: zip, address_country: address_country,
171
+ contact_first_name: contact_first_name,
172
+ contact_last_name: contact_last_name, contact_email: contact_email,
173
+ contact_phone: contact_phone, monthly_volume: monthly_volume,
174
+ use_case: use_case, use_case_summary: use_case_summary,
175
+ sample_messages: sample_messages, opt_in_workflow: opt_in_workflow,
176
+ privacy_url: privacy_url, terms_url: terms_url,
177
+ additional_information: additional_information,
178
+ age_gated_content: age_gated_content
179
+ )
180
+
181
+ post_multipart_with_fields(
182
+ "/workspaces/#{url_escape(workspace_id)}/upgrade",
183
+ fields,
184
+ ein_doc_path: ein_doc_path,
185
+ ein_doc: ein_doc,
186
+ ein_doc_filename: ein_doc_filename,
187
+ ein_doc_content_type: ein_doc_content_type
188
+ )
189
+ end
190
+
191
+ # Check whether the given workspace has a pending entity upgrade.
192
+ # Returns +{ "pending" => nil }+ when no upgrade is in flight.
193
+ #
194
+ # @param workspace_id [String]
195
+ # @return [Hash]
196
+ def status(workspace_id)
197
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
198
+
199
+ @client.get("/workspaces/#{url_escape(workspace_id)}/upgrade/status")
200
+ end
201
+
202
+ # Cancel a pending entity upgrade for the given workspace. Releases
203
+ # the reserved toll-free number, deletes the new messaging profile,
204
+ # and removes the stored EIN document. Idempotent.
205
+ #
206
+ # @param workspace_id [String]
207
+ # @return [Hash]
208
+ def cancel(workspace_id)
209
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
210
+
211
+ @client.post("/workspaces/#{url_escape(workspace_id)}/upgrade/cancel", {})
212
+ end
213
+
214
+ # Resubmit a rejected (or waiting-for-customer) entity upgrade with
215
+ # updated fields and optionally a new EIN document. All fields are
216
+ # optional — only the ones you pass are sent.
217
+ #
218
+ # @param workspace_id [String]
219
+ # @return [Hash]
220
+ def resubmit(workspace_id, business_name: nil, brn: nil, brn_type: nil,
221
+ brn_country: nil, entity_type: nil, doing_business_as: nil,
222
+ website: nil, address1: nil, address2: nil, city: nil,
223
+ state: nil, zip: nil, address_country: nil,
224
+ contact_first_name: nil, contact_last_name: nil,
225
+ contact_email: nil, contact_phone: nil, monthly_volume: nil,
226
+ use_case: nil, use_case_summary: nil, sample_messages: nil,
227
+ opt_in_workflow: nil, privacy_url: nil, terms_url: nil,
228
+ additional_information: nil, age_gated_content: nil,
229
+ ein_doc_path: nil, ein_doc: nil, ein_doc_filename: nil,
230
+ ein_doc_content_type: nil)
231
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
232
+
233
+ fields = build_upgrade_body(
234
+ business_name: business_name, brn: brn, brn_type: brn_type,
235
+ brn_country: brn_country, entity_type: entity_type,
236
+ doing_business_as: doing_business_as, website: website,
237
+ address1: address1, address2: address2, city: city, state: state,
238
+ zip: zip, address_country: address_country,
239
+ contact_first_name: contact_first_name,
240
+ contact_last_name: contact_last_name, contact_email: contact_email,
241
+ contact_phone: contact_phone, monthly_volume: monthly_volume,
242
+ use_case: use_case, use_case_summary: use_case_summary,
243
+ sample_messages: sample_messages, opt_in_workflow: opt_in_workflow,
244
+ privacy_url: privacy_url, terms_url: terms_url,
245
+ additional_information: additional_information,
246
+ age_gated_content: age_gated_content
247
+ )
248
+
249
+ post_multipart_with_fields(
250
+ "/workspaces/#{url_escape(workspace_id)}/upgrade/resubmit",
251
+ fields,
252
+ ein_doc_path: ein_doc_path,
253
+ ein_doc: ein_doc,
254
+ ein_doc_filename: ein_doc_filename,
255
+ ein_doc_content_type: ein_doc_content_type
256
+ )
257
+ end
258
+
259
+ # After a successful entity-upgrade approval, choose what happens to
260
+ # the old toll-free number:
261
+ #
262
+ # - +"moved"+: keep it active under another workspace owned by the
263
+ # same user (requires +target_workspace_id+)
264
+ # - +"released"+: return it to the carrier pool
265
+ #
266
+ # @param workspace_id [String]
267
+ # @param disposition [String] +"moved"+ or +"released"+
268
+ # @param target_workspace_id [String, nil] Required when +disposition+ is +"moved"+
269
+ # @return [Hash]
270
+ def set_disposition(workspace_id, disposition:, target_workspace_id: nil)
271
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
272
+ unless DISPOSITIONS.include?(disposition)
273
+ raise ArgumentError, "disposition must be one of: #{DISPOSITIONS.join(", ")}"
274
+ end
275
+ if disposition == "moved" && (target_workspace_id.nil? || target_workspace_id.empty?)
276
+ raise ArgumentError, "target_workspace_id is required when disposition is 'moved'"
277
+ end
278
+
279
+ body = { disposition: disposition }
280
+ body[:targetOrgId] = target_workspace_id if target_workspace_id
281
+
282
+ @client.post("/workspaces/#{url_escape(workspace_id)}/upgrade/disposition", body)
283
+ end
284
+
285
+ private
286
+
287
+ def url_escape(value)
288
+ URI.encode_www_form_component(value)
289
+ end
290
+
291
+ def build_upgrade_body(business_name:, brn:, brn_type:, brn_country:,
292
+ entity_type:, doing_business_as:, website:,
293
+ address1:, address2:, city:, state:, zip:,
294
+ address_country:, contact_first_name:,
295
+ contact_last_name:, contact_email:, contact_phone:,
296
+ monthly_volume:, use_case:, use_case_summary:,
297
+ sample_messages:, opt_in_workflow:, privacy_url:,
298
+ terms_url:, additional_information:,
299
+ age_gated_content:)
300
+ body = {}
301
+ body[:businessName] = business_name unless business_name.nil?
302
+ body[:brn] = brn unless brn.nil?
303
+ body[:brnType] = brn_type unless brn_type.nil?
304
+ body[:brnCountry] = brn_country unless brn_country.nil?
305
+ body[:entityType] = entity_type unless entity_type.nil?
306
+ body[:doingBusinessAs] = doing_business_as unless doing_business_as.nil?
307
+ body[:website] = website unless website.nil?
308
+ body[:address1] = address1 unless address1.nil?
309
+ body[:address2] = address2 unless address2.nil?
310
+ body[:city] = city unless city.nil?
311
+ body[:state] = state unless state.nil?
312
+ body[:zip] = zip unless zip.nil?
313
+ body[:addressCountry] = address_country unless address_country.nil?
314
+ body[:contactFirstName] = contact_first_name unless contact_first_name.nil?
315
+ body[:contactLastName] = contact_last_name unless contact_last_name.nil?
316
+ body[:contactEmail] = contact_email unless contact_email.nil?
317
+ body[:contactPhone] = contact_phone unless contact_phone.nil?
318
+ body[:monthlyVolume] = monthly_volume unless monthly_volume.nil?
319
+ body[:useCase] = use_case unless use_case.nil?
320
+ body[:useCaseSummary] = use_case_summary unless use_case_summary.nil?
321
+ body[:sampleMessages] = sample_messages unless sample_messages.nil?
322
+ body[:optInWorkflow] = opt_in_workflow unless opt_in_workflow.nil?
323
+ body[:privacyUrl] = privacy_url unless privacy_url.nil?
324
+ body[:termsUrl] = terms_url unless terms_url.nil?
325
+ body[:additionalInformation] = additional_information unless additional_information.nil?
326
+ body[:ageGatedContent] = age_gated_content unless age_gated_content.nil?
327
+ body
328
+ end
329
+
330
+ # POST a multipart/form-data request with the given fields plus an
331
+ # optional +einDoc+ file part. Mirrors the JSON-vs-multipart flexibility
332
+ # the server expects: if no EIN doc is supplied and there are no fields,
333
+ # we still send an empty multipart body (the server is configured for
334
+ # +multer+ on these routes).
335
+ def post_multipart_with_fields(path, fields, ein_doc_path:, ein_doc:,
336
+ ein_doc_filename:, ein_doc_content_type:)
337
+ if !ein_doc_path.nil? && !ein_doc.nil?
338
+ raise ArgumentError, "Provide ein_doc_path OR ein_doc, not both"
339
+ end
340
+
341
+ file_bytes = nil
342
+ filename = ein_doc_filename || "ein-doc.pdf"
343
+ content_type = ein_doc_content_type || "application/pdf"
344
+
345
+ if ein_doc_path
346
+ raise ArgumentError, "File not found: #{ein_doc_path}" unless File.exist?(ein_doc_path)
347
+ file_bytes = File.binread(ein_doc_path)
348
+ filename = ein_doc_filename || File.basename(ein_doc_path)
349
+ elsif ein_doc
350
+ file_bytes = ein_doc.is_a?(String) ? ein_doc : ein_doc.read
351
+ end
352
+
353
+ boundary = "SendlyRuby#{SecureRandom.hex(16)}"
354
+ body_parts = []
355
+
356
+ fields.each do |k, v|
357
+ next if v.nil?
358
+ body_parts << "--#{boundary}\r\n"
359
+ body_parts << "Content-Disposition: form-data; name=\"#{k}\"\r\n\r\n"
360
+ body_parts << (v == true || v == false ? v.to_s : v.to_s)
361
+ body_parts << "\r\n"
362
+ end
363
+
364
+ if file_bytes
365
+ body_parts << "--#{boundary}\r\n"
366
+ body_parts << "Content-Disposition: form-data; name=\"einDoc\"; filename=\"#{filename}\"\r\n"
367
+ body_parts << "Content-Type: #{content_type}\r\n\r\n"
368
+ body_parts << file_bytes
369
+ body_parts << "\r\n"
370
+ end
371
+
372
+ body_parts << "--#{boundary}--\r\n"
373
+
374
+ uri = URI.parse("#{@client.base_url}#{path}")
375
+ http = Net::HTTP.new(uri.host, uri.port)
376
+ http.use_ssl = uri.scheme == "https"
377
+ http.open_timeout = 10
378
+ http.read_timeout = @client.timeout
379
+
380
+ req = Net::HTTP::Post.new(uri)
381
+ req["Authorization"] = "Bearer #{@client.api_key}"
382
+ req["Accept"] = "application/json"
383
+ req["User-Agent"] = "sendly-ruby/#{Sendly::VERSION}"
384
+ req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
385
+ req["X-Organization-Id"] = @client.organization_id if @client.organization_id
386
+ req.body = body_parts.join
387
+
388
+ begin
389
+ response = http.request(req)
390
+ rescue Net::OpenTimeout, Net::ReadTimeout
391
+ raise Sendly::TimeoutError, "Request timed out after #{@client.timeout} seconds"
392
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
393
+ raise Sendly::NetworkError, "Connection failed: #{e.message}"
394
+ end
395
+
396
+ status = response.code.to_i
397
+ body = response.body.nil? || response.body.empty? ? {} : (JSON.parse(response.body) rescue { "message" => response.body })
398
+
399
+ return body if status >= 200 && status < 300
400
+
401
+ raise Sendly::ErrorFactory.from_response(status, body)
402
+ end
403
+ end
404
+ end
data/lib/sendly/client.rb CHANGED
@@ -150,6 +150,20 @@ module Sendly
150
150
  @enterprise ||= EnterpriseResource.new(self)
151
151
  end
152
152
 
153
+ # Access the Business Upgrade resource (entity-upgrade flow)
154
+ #
155
+ # @return [Sendly::BusinessUpgradeResource]
156
+ def business_upgrade
157
+ @business_upgrade ||= BusinessUpgradeResource.new(self)
158
+ end
159
+
160
+ # Access the Numbers resource
161
+ #
162
+ # @return [Sendly::NumbersResource]
163
+ def numbers
164
+ @numbers ||= NumbersResource.new(self)
165
+ end
166
+
153
167
  # Make a GET request
154
168
  #
155
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
 
@@ -0,0 +1,248 @@
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
+
47
+ def initialize(data)
48
+ @id = data["id"]
49
+ @phone_number = data["phoneNumber"] || data["phone_number"]
50
+ @status = data["status"]
51
+ @source = data["source"]
52
+ @country_code = data["countryCode"] || data["country_code"]
53
+ @phone_number_type = data["phoneNumberType"] || data["phone_number_type"]
54
+ @monthly_cost_cents = data["monthlyCostCents"] || data["monthly_cost_cents"]
55
+ end
56
+
57
+ def to_h
58
+ {
59
+ id: id, phone_number: phone_number, status: status, source: source,
60
+ country_code: country_code, phone_number_type: phone_number_type,
61
+ monthly_cost_cents: monthly_cost_cents
62
+ }.compact
63
+ end
64
+ end
65
+
66
+ # The result of a buy request. The API responds 202 with one of three
67
+ # statuses:
68
+ #
69
+ # - +"provisioning"+: the purchase succeeded and the number is being
70
+ # provisioned. +number+ carries the new {PhoneNumber}.
71
+ # - +"documents_required"+ / +"payment_required"+: the purchase is paused
72
+ # pending a hosted Sendly step. +action+ carries the hand-off object,
73
+ # which holds TWO distinct identifiers:
74
+ # - +actionCode+ — a 32-hex action identifier (read via {#action_identifier}).
75
+ # Use THIS to poll the action and to re-call +buy+ (pass it as the
76
+ # +action_code:+ argument).
77
+ # - +code+ — a short user code (read via {#action_code}). DISPLAY ONLY:
78
+ # show it to the human to type on the hosted page to prove terminal
79
+ # access. Never pass it back as +action_code:+.
80
+ # Hand the user +action_url+ + {#action_code}, wait for completion, then
81
+ # re-call +buy+ with the same body plus +action_code:+ set to
82
+ # {#action_identifier}. +requirements+ describes what's missing (a JSON
83
+ # array).
84
+ #
85
+ # The raw parsed response is preserved verbatim on +#raw+ so callers can
86
+ # read any field the server adds.
87
+ class NumberPurchase
88
+ attr_reader :status, :number, :requirements, :action, :raw
89
+
90
+ def initialize(data)
91
+ @raw = data
92
+ @status = data["status"]
93
+ @number = data["number"] ? PhoneNumber.new(data["number"]) : nil
94
+ @requirements = data["requirements"]
95
+ @action = data["action"]
96
+ end
97
+
98
+ def provisioning?
99
+ status == "provisioning"
100
+ end
101
+
102
+ def documents_required?
103
+ status == "documents_required"
104
+ end
105
+
106
+ def payment_required?
107
+ status == "payment_required"
108
+ end
109
+
110
+ # @return [String, nil] The hosted Sendly page URL the user must visit.
111
+ def action_url
112
+ action && (action["url"] || action[:url])
113
+ end
114
+
115
+ # The 32-hex action identifier. Use THIS to poll the action's status and
116
+ # to re-call +buy+ (pass it as the +action_code:+ argument). NOT for
117
+ # display.
118
+ #
119
+ # @return [String, nil]
120
+ def action_identifier
121
+ action && (action["actionCode"] || action["action_code"] || action[:actionCode] || action[:action_code])
122
+ end
123
+
124
+ # The short user code shown to the human to type on the hosted page to
125
+ # prove terminal access. DISPLAY ONLY — to re-buy/poll, use
126
+ # {#action_identifier}, not this.
127
+ #
128
+ # @return [String, nil]
129
+ def action_code
130
+ action && (action["code"] || action[:code])
131
+ end
132
+
133
+ # Expiry of the action, as an epoch-milliseconds number (the server sends
134
+ # a number, not an ISO-8601 string). Older payloads may carry it under
135
+ # +expires_at+; both are accepted.
136
+ #
137
+ # @return [Integer, String, nil]
138
+ def action_expires_at
139
+ action && (action["expiresAt"] || action["expires_at"] || action[:expiresAt] || action[:expires_at])
140
+ end
141
+
142
+ def to_h
143
+ {
144
+ status: status, number: number&.to_h,
145
+ requirements: requirements, action: action
146
+ }.compact
147
+ end
148
+ end
149
+
150
+ # Numbers resource — search, list, and purchase phone numbers.
151
+ #
152
+ # @example List supported countries
153
+ # result = client.numbers.list_countries
154
+ # result[:countries].each { |c| puts "#{c.code} #{c.name}" }
155
+ #
156
+ # @example Find available mobile numbers in the UK
157
+ # result = client.numbers.list_available(country: "GB", type: "mobile")
158
+ # number = result[:numbers].first
159
+ #
160
+ # @example Buy a number (may pause for a hosted step)
161
+ # purchase = client.numbers.buy(
162
+ # phone_number: number.phone_number,
163
+ # country_code: number.country,
164
+ # phone_number_type: number.number_type,
165
+ # monthly_cost: number.monthly_cost
166
+ # )
167
+ # if purchase.documents_required? || purchase.payment_required?
168
+ # # Show the user the URL + display code; keep the 32-hex identifier for re-buy
169
+ # puts "Visit #{purchase.action_url} and enter code #{purchase.action_code}"
170
+ # # ...once they finish, re-call buy with action_code: purchase.action_identifier
171
+ # end
172
+ class NumbersResource
173
+ def initialize(client)
174
+ @client = client
175
+ end
176
+
177
+ # List the countries in which numbers can be searched and purchased,
178
+ # along with the number types available in each.
179
+ #
180
+ # @return [Hash] +{ countries: Array<NumberCountry> }+
181
+ def list_countries
182
+ response = @client.get("/numbers/countries")
183
+ countries = (response["countries"] || []).map { |c| NumberCountry.new(c) }
184
+ { countries: countries }
185
+ end
186
+
187
+ # Search for numbers available to purchase in a country.
188
+ #
189
+ # @param country [String] ISO country code (e.g. "GB")
190
+ # @param type [String] Number type (e.g. "mobile", "local", "toll_free")
191
+ # @param contains [String, nil] Optional digit/letter filter
192
+ # @return [Hash] +{ numbers: Array<AvailableNumber> }+
193
+ def list_available(country:, type:, contains: nil)
194
+ raise ValidationError, "country is required" if country.nil? || country.to_s.empty?
195
+ raise ValidationError, "type is required" if type.nil? || type.to_s.empty?
196
+
197
+ params = { country: country, type: type }
198
+ params[:contains] = contains if contains
199
+
200
+ response = @client.get("/numbers/available", params)
201
+ numbers = (response["numbers"] || []).map { |n| AvailableNumber.new(n) }
202
+ { numbers: numbers }
203
+ end
204
+
205
+ # List the numbers owned by the account.
206
+ #
207
+ # @return [Hash] +{ numbers: Array<PhoneNumber> }+
208
+ def list
209
+ response = @client.get("/numbers")
210
+ numbers = (response["numbers"] || []).map { |n| PhoneNumber.new(n) }
211
+ { numbers: numbers }
212
+ end
213
+
214
+ # Buy a number.
215
+ #
216
+ # Returns a {NumberPurchase}. When its status is +documents_required+ or
217
+ # +payment_required+, hand the user +purchase.action_url+ +
218
+ # +purchase.action_code+ (the short display code), wait for that hosted
219
+ # step to complete, then re-call +buy+ with the SAME arguments plus
220
+ # +action_code:+ set to +purchase.action_identifier+ (the 32-hex action
221
+ # identifier) — NOT the display code.
222
+ #
223
+ # @param phone_number [String]
224
+ # @param country_code [String]
225
+ # @param phone_number_type [String]
226
+ # @param monthly_cost [String] Customer-priced monthly cost (as returned by {#list_available})
227
+ # @param action_code [String, nil] The 32-hex action identifier of a
228
+ # completed hosted action (see {NumberPurchase#action_identifier}), on re-call
229
+ # @return [NumberPurchase]
230
+ def buy(phone_number:, country_code:, phone_number_type:, monthly_cost:, action_code: nil)
231
+ raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
232
+ raise ValidationError, "country_code is required" if country_code.nil? || country_code.to_s.empty?
233
+ raise ValidationError, "phone_number_type is required" if phone_number_type.nil? || phone_number_type.to_s.empty?
234
+ raise ValidationError, "monthly_cost is required" if monthly_cost.nil? || monthly_cost.to_s.empty?
235
+
236
+ body = {
237
+ phoneNumber: phone_number,
238
+ countryCode: country_code,
239
+ phoneNumberType: phone_number_type,
240
+ monthlyCost: monthly_cost
241
+ }
242
+ body[:actionCode] = action_code if action_code
243
+
244
+ response = @client.post("/numbers/buy", body)
245
+ NumberPurchase.new(response)
246
+ end
247
+ end
248
+ 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.31.0"
4
+ VERSION = "3.34.0"
5
5
  end
data/lib/sendly.rb CHANGED
@@ -21,6 +21,8 @@ require_relative "sendly/labels_resource"
21
21
  require_relative "sendly/drafts_resource"
22
22
  require_relative "sendly/rules_resource"
23
23
  require_relative "sendly/enterprise"
24
+ require_relative "sendly/business_upgrade_resource"
25
+ require_relative "sendly/numbers_resource"
24
26
 
25
27
  # Sendly Ruby SDK
26
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.31.0
4
+ version: 3.34.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-16 00:00:00.000000000 Z
11
+ date: 2026-06-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -125,6 +125,7 @@ files:
125
125
  - examples/send_sms.rb
126
126
  - lib/sendly.rb
127
127
  - lib/sendly/account_resource.rb
128
+ - lib/sendly/business_upgrade_resource.rb
128
129
  - lib/sendly/campaigns_resource.rb
129
130
  - lib/sendly/client.rb
130
131
  - lib/sendly/contacts_resource.rb
@@ -135,6 +136,7 @@ files:
135
136
  - lib/sendly/labels_resource.rb
136
137
  - lib/sendly/media.rb
137
138
  - lib/sendly/messages.rb
139
+ - lib/sendly/numbers_resource.rb
138
140
  - lib/sendly/rules_resource.rb
139
141
  - lib/sendly/templates_resource.rb
140
142
  - lib/sendly/types.rb