sendly 3.40.0 → 4.1.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: 91dd6ee6db6b506dea8d70b085fc6a069330e9ddca10b1de8e097b3db07889cd
4
- data.tar.gz: 3114725bc7d45361b625ba7c7cfc88b6a6eee0672154b3b3daf46237cb475b53
3
+ metadata.gz: 84b10285655be846bb857f5e11388a893d916838d480accfc46a646c16dd68a3
4
+ data.tar.gz: cfe12c76eeea0a6db834c09d97c7546260c4a313f5f6d9c64a549d467884ae3b
5
5
  SHA512:
6
- metadata.gz: a8728f247769f3bbd8ad1713a86814eb419c944d64f06797ed94f858b543185d4c1baced35ba9aa910871b04a15e0348aece222a4e88de691e717df9b5542c01
7
- data.tar.gz: ba2ad16077e383a0e37b7dee97ebada69cd3c1e815f6775afb677ab331a031da2f643666da3e95cc2b90e0dbff3478a6d35bc9a0364efd9b865c1ff5302f25fd
6
+ metadata.gz: 5c528f37943ed1c30eb8b6929097cb167ba9be070e0a8b3ec6d69949e9710a38ccbf1bdb04ce57d92a569fabdb68e56df3e50104e3ffffb620d9c6962d3e4e73
7
+ data.tar.gz: 73c7ba0af6ac53f75b33759b2566292f64ee5199c13f088da4ebb3b8bf68afea4b00fdddb76a0e7f3a7a1fa1547162ab94fa81a119d0b18004bce650ca6ed434
data/CHANGELOG.md CHANGED
@@ -1,6 +1,24 @@
1
1
  # sendly (Ruby)
2
2
 
3
- ## Unreleased
3
+ ## 4.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **Voice calls: `client.calls`.** Place phone calls handled by your AI agents, list and inspect them, end one early and fetch recordings, over the new `/api/v1/calls` routes. `create(to:, agent_id:, from: nil, context: nil, metadata: nil)` returns a `Sendly::Call` that is `ringing`; `list` takes `limit:`, `offset:`, `status:`, `direction:`, `kind:`, `agent_id:`, `to:` and `from:` and returns an Enumerable `Sendly::CallList` with `total`, `limit`, `offset` and `has_more?`; `get(id)` adds the `transcript` (an array of `Sendly::CallTranscriptLine`) on agent-handled calls and leaves it `nil` otherwise; `hangup(id)` cancels a ringing call or completes an active one and returns an already-ended call unchanged; `recording(id)` returns a `Sendly::CallRecording` whose signed `url` is set only while `ready?` and expires after five minutes. `create` and `hangup` send the client's usual `Idempotency-Key` and accept `idempotency_key:`. `Sendly::Call` also reads the snake_case object carried by the `call.started`, `call.completed` and `call.recording.ready` webhooks, including the new `billing` and `metadata` keys. Vocabularies are published as `Sendly::Call::STATUSES`, `::HANGUP_CLASSES` and `::ERROR_CODES`. `Sendly::PhoneNumber` gains `voice_enabled` (with `voice_enabled?`), `voice_mode` (`Sendly::PhoneNumber::VOICE_MODES`) and `raw`, so `client.numbers.list` can pick the `from` number for a call. Reads need the `calls:read` scope; writes need `calls:write` and a live key. Voice is enabled workspace by workspace: until it is on for yours the routes answer 404 `voice_not_enabled`, which raises `Sendly::NotFoundError`.
8
+
9
+ ```ruby
10
+ call = client.calls.create(
11
+ to: "+15555550123",
12
+ agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
13
+ context: "Confirm the 3pm appointment on Tuesday."
14
+ )
15
+ call = client.calls.get(call.id)
16
+ call.transcript&.each { |line| puts "#{line.speaker}: #{line.text}" }
17
+ ```
18
+
19
+ ## 4.0.0
20
+
21
+ **Upgrading from 3.40.0:** that release already contained the breaking changes below, published by mistake as a minor version. 4.0.0 carries them under the correct major. Relative to 3.40.0, the only new changes are under **Security**.
4
22
 
5
23
  ### Breaking Changes
6
24
 
@@ -22,6 +40,120 @@
22
40
 
23
41
  - **`id` is no longer filled from an unrelated `id` key.** `contact.auto_flagged` carries the contact under `id` and the message that failed under `message_id`, so `event.data.message_id` returned the *contact* id — a handler that marked that message failed acted on the wrong row. Contact events have no message view at all now; read the message with `event.data[:message_id]`.
24
42
 
43
+ ### Migrating from 3.x
44
+
45
+ Nothing outside webhook handling changed. The client, every resource and every
46
+ `message.*` handler you already have keep working as written; the list below is
47
+ the whole edit.
48
+
49
+ **1. Reading a message field off a lifecycle event now raises.** 3.x decoded
50
+ every `data.object` into a `Sendly::WebhookMessageData`, so an `rcs_agent.live`
51
+ handler that asked for `event.data.from` was handed `""`, `event.data.segments`
52
+ `1` and `event.data.credits_used` `0` — values the event never carried, not
53
+ distinguishable from real ones, and no error was raised. The same calls raise
54
+ `NoMethodError` in 4.0, naming the keys that did arrive. That failure is the
55
+ point of this release rather than an accident of it: the value it replaces was
56
+ wrong, and silently so.
57
+
58
+ ```ruby
59
+ # 3.x — silently wrong, on every RCS event
60
+ event.data.from # => ""
61
+ event.data.segments # => 1
62
+
63
+ # 4.0 — the same call
64
+ event.data.from
65
+ # => NoMethodError: undefined method 'from' for Sendly::WebhookObject:
66
+ # this event's data.object carries agent_id, name, stage, organization_id
67
+
68
+ # 4.0 — the edit: read the object the event actually carries
69
+ event.data[:agent_id] # => "bb22cc33-dd44-4e55-9f66-001122334455"
70
+ event.data.stage # => "live"
71
+ event.raw_object # => the whole data.object Hash, untouched
72
+ ```
73
+
74
+ **2. `event.data` is a message view only on `message.*` events.** In 3.x it was
75
+ a `WebhookMessageData` whatever the event was, so `event.data.id` and
76
+ `event.data.status` answered for anything — with the payload's value when the
77
+ key happened to exist, and with `''` or `nil` when it did not. In 4.0 a
78
+ lifecycle event's `event.data` is a `Sendly::WebhookObject`: the keys the
79
+ payload carried are readable and nothing else is. `event.message` is new in 4.0
80
+ and is the message view; it is `nil` outside `message.*`, so guard it with
81
+ `event.message?` rather than calling into it unconditionally.
82
+
83
+ ```ruby
84
+ # 3.x — answered for every event type; "" when the payload had no id
85
+ mark_delivered(event.data.id)
86
+
87
+ # 4.0 — split the branches
88
+ case event.type
89
+ when Sendly::Webhooks::EVENT_MESSAGE_DELIVERED
90
+ mark_delivered(event.message.id) # same value as 3.x
91
+ when Sendly::Webhooks::EVENT_NUMBER_ACTIVATED
92
+ number_active(event.data[:id]) # the number's id, as it always was
93
+ when Sendly::Webhooks::EVENT_RCS_AGENT_LIVE
94
+ agent_went_live(event.data[:agent_id]) # unreachable in 3.x
95
+ end
96
+ ```
97
+
98
+ **3. `contact.auto_flagged` no longer reports the contact as the message.** The
99
+ payload carries the contact under `id` and the message that failed under
100
+ `message_id`; 3.x filled the message view's `id` from the contact, so a handler
101
+ that marked "the message" failed acted on the wrong record.
102
+
103
+ ```ruby
104
+ # 3.x — the CONTACT id, presented as a message id
105
+ event.data.message_id # => "ct_9"
106
+
107
+ # 4.0
108
+ event.data[:id] # => "ct_9" the contact
109
+ event.data[:message_id] # => "msg_77" the message that failed
110
+ event.message # => nil
111
+ ```
112
+
113
+ **4. Message fields the payload omitted are `nil`, not a default.** On a
114
+ `message.*` event the readers still exist, so this raises nothing — it changes
115
+ what you get. `key?` separates "absent" from "arrived as null".
116
+
117
+ ```ruby
118
+ # message.delivered, on a payload that carried no segments
119
+ event.data.segments # 3.x => 1 4.0 => nil
120
+ event.data.credits_used # 3.x => 0 4.0 => nil
121
+ event.data.direction # 3.x => "outbound" 4.0 => nil
122
+ event.data.key?(:segments) # => false when absent, true when it arrived as null
123
+ ```
124
+
125
+ **5. `event.data.to_h` is now `data.object` as it arrived.** It used to be a
126
+ compacted subset of the typed message fields, dropping `text`, `metadata`,
127
+ `media_urls`, `message_format` and `organization_id` and renaming `message_id`
128
+ to `id`. Code that persisted `to_h` will start seeing the full object.
129
+
130
+ ```ruby
131
+ event.data.to_h == event.raw_object # => true, for every event type
132
+ ```
133
+
134
+ **6. If you want a typed object for a lifecycle event, ask for one.**
135
+ `#object_as` fills a Struct or Data class from the members it declares and
136
+ ignores the rest of the payload, so a field added to the event later cannot
137
+ break the call.
138
+
139
+ ```ruby
140
+ AgentLive = Struct.new(:agent_id, :name, :stage)
141
+ agent = event.object_as(AgentLive)
142
+ agent.stage # => "live"
143
+ ```
144
+
145
+ ### Deprecations
146
+
147
+ - **`Sendly::Webhooks::EVENT_MESSAGE_QUEUED` is deprecated.** The API has never
148
+ emitted `message.queued`, and subscribing to it fails: `client.webhooks.create`
149
+ and `client.webhooks.update` reject any event outside the API's list with a
150
+ 400, raised here as `Sendly::ValidationError`. The constant stays exported in
151
+ 4.0 so existing code still loads, and will be removed in the next major.
152
+ `message.undelivered` is rejected on subscribe the same way and has never had
153
+ a constant in this SDK. Subscribe to `message.sent`, `message.failed` and
154
+ `message.bounced` instead. Every other `EVENT_*` constant matches the API's
155
+ list exactly.
156
+
25
157
  ### Minor Changes
26
158
 
27
159
  - **`Sendly::WebhookEvent#raw_object`** carries `data.object` exactly as it arrived, for every event type, and **`#object_as(klass)`** reads it as a type of your choosing (`event.object_as(AgentLive)`). `#object` is an alias for `raw_object`.
@@ -48,6 +180,11 @@
48
180
 
49
181
  - **`Sendly::ValidationError#field_errors` carries the API's `errors` list** (`[{ "path", "message" }, ...]`) when a 400 or 422 response includes one, instead of always being `nil`. RCS registration uses it to say which brand, agent, campaign or device field needs attention.
50
182
 
183
+
184
+ ### Security
185
+
186
+ - **Path parameters are percent-encoded.** Every id you pass is now encoded (`URI.encode_www_form_component`) before it goes into the request path. An id containing `/`, `?` or `#` used to change which endpoint the request reached: an id of `../../account/keys` left its collection and hit another endpoint carrying your API key. Ordinary ids are sent byte-for-byte as before.
187
+
51
188
  ## 3.38.0
52
189
 
53
190
  ### Minor Changes
data/Gemfile.lock CHANGED
@@ -1,14 +1,14 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sendly (3.40.0)
4
+ sendly (4.1.0)
5
5
  faraday (~> 2.0)
6
6
  faraday-retry (~> 2.0)
7
7
 
8
8
  GEM
9
9
  remote: https://rubygems.org/
10
10
  specs:
11
- addressable (2.9.0)
11
+ addressable (2.8.8)
12
12
  public_suffix (>= 2.0.2, < 8.0)
13
13
  ast (2.4.3)
14
14
  bigdecimal (3.3.1)
@@ -74,7 +74,7 @@ GEM
74
74
  unicode-emoji (~> 4.1)
75
75
  unicode-emoji (4.1.0)
76
76
  uri (1.1.1)
77
- webmock (3.26.4)
77
+ webmock (3.26.1)
78
78
  addressable (>= 2.8.0)
79
79
  crack (>= 0.3.2)
80
80
  hashdiff (>= 0.4.0, < 2.0.0)
data/README.md CHANGED
@@ -342,6 +342,16 @@ rotation = client.webhooks.rotate_secret("whk_xxx")
342
342
  client.webhooks.delete("whk_xxx")
343
343
  ```
344
344
 
345
+ Subscribe with the `Sendly::Webhooks::EVENT_*` constants rather than string
346
+ literals — a typo then fails at load time instead of in a 400. Every constant
347
+ but one names an event the API accepts on subscribe. The exception is
348
+ `EVENT_MESSAGE_QUEUED`: `message.queued` has never been emitted and is
349
+ rejected on subscribe with a 400 (`Sendly::ValidationError`). It is
350
+ deprecated, kept only so existing code still loads, and will be removed in the
351
+ next major. `message.undelivered` is rejected in the same way and has never had
352
+ a constant here. Subscribe to `message.sent`, `message.failed` and
353
+ `message.bounced` instead.
354
+
345
355
  ### Receiving events
346
356
 
347
357
  `Sendly::Webhooks.parse_event` verifies the signature and returns a
@@ -403,6 +413,66 @@ Note that `contact.auto_flagged` carries the contact under `id` and the message
403
413
  that failed under `message_id`; read the message with
404
414
  `event.data[:message_id]`.
405
415
 
416
+ ### Handling a lifecycle event
417
+
418
+ Only `message.*` events carry a message. A lifecycle event — `rcs_*`,
419
+ `whatsapp_*`, `call.*`, `brand.*`, `campaign.*`, `assignment.*`, `number.*`,
420
+ `port*`, `contact*`, `conversation.*`, `draft.*` — carries a different object,
421
+ so `event.message` is `nil` and you read `data.object` off `event.data` or
422
+ `event.raw_object`.
423
+
424
+ ```ruby
425
+ require "sendly"
426
+
427
+ # Framework-neutral: hand it the raw request body and a headers Hash.
428
+ def handle_sendly_webhook(raw_body, headers)
429
+ event = Sendly::Webhooks.parse_event(
430
+ raw_body,
431
+ headers["X-Sendly-Signature"],
432
+ ENV.fetch("SENDLY_WEBHOOK_SECRET"),
433
+ timestamp: headers["X-Sendly-Timestamp"]
434
+ )
435
+
436
+ case event.type
437
+ when Sendly::Webhooks::EVENT_RCS_AGENT_LIVE
438
+ # data.object is the agent: agent_id, name, stage
439
+ puts "RCS agent #{event.data[:name]} is #{event.data.stage}"
440
+ puts event.data[:agent_id]
441
+ when Sendly::Webhooks::EVENT_NUMBER_ACTIVATED
442
+ # data.object is the number: id, phone, status, country_code, source
443
+ puts "#{event.data[:phone]} active in #{event.data[:country_code]}"
444
+ when Sendly::Webhooks::EVENT_CONTACT_AUTO_FLAGGED
445
+ # the contact is `id`; the message that failed is `message_id`
446
+ puts "flagged #{event.data[:id]} (#{event.data[:invalid_reason]})"
447
+ puts "from message #{event.data[:message_id]}"
448
+ when Sendly::Webhooks::EVENT_MESSAGE_DELIVERED
449
+ # message.* events, and only these, also get the typed message view
450
+ puts "#{event.message.id} delivered to #{event.message.to}"
451
+ else
452
+ # An event type this SDK predates still parses; raw_object holds all of it.
453
+ puts "unhandled #{event.type}: #{event.raw_object.inspect}"
454
+ end
455
+
456
+ :ok
457
+ rescue Sendly::WebhookSignatureError
458
+ :unauthorized
459
+ end
460
+ ```
461
+
462
+ Reading a message field off a lifecycle event fails loudly rather than
463
+ answering with a value the event never carried:
464
+
465
+ ```ruby
466
+ event.data.from
467
+ # => NoMethodError: undefined method 'from' for Sendly::WebhookObject:
468
+ # this event's data.object carries agent_id, name, stage, organization_id
469
+ event.message # => nil
470
+ event.message.id # => NoMethodError — nil has no #id
471
+ ```
472
+
473
+ Branch on `event.type`, or on `event.message?` / `event.verification?`, before
474
+ reaching for a typed view.
475
+
406
476
  ## Account & Credits
407
477
 
408
478
  ```ruby
@@ -732,6 +802,7 @@ end
732
802
  # Get one number you own (includes is_default, which the list omits)
733
803
  number = client.numbers.get('num_abc123')
734
804
  puts "#{number.phone_number} — default sender: #{number.is_default}"
805
+ puts "voice: #{number.voice_enabled?} (#{number.voice_mode})"
735
806
 
736
807
  # Update a number — make it the default sender (must be active),
737
808
  # and/or cancel a scheduled release ("keep this number")
@@ -1101,6 +1172,88 @@ client.messages.send(
1101
1172
  )
1102
1173
  ```
1103
1174
 
1175
+ ## Voice Calls
1176
+
1177
+ Place phone calls that one of your AI agents handles, list and inspect
1178
+ calls, end a call early, and download recordings. Agents are configured in
1179
+ the dashboard under Calls, then Agents; the number you call from must have
1180
+ voice switched on there (Calls, then Settings) and an emergency address
1181
+ registered before it can place outbound calls. Each `Sendly::PhoneNumber`
1182
+ from `client.numbers.list` carries `voice_enabled?` and `voice_mode`
1183
+ (`"none"`, `"ring_dashboard"` or `"agent"`) so you can pick a `from`:
1184
+
1185
+ ```ruby
1186
+ from = client.numbers.list[:numbers].find(&:voice_enabled?)&.phone_number
1187
+ ```
1188
+
1189
+ Calls are prepaid from your credit balance per started minute: 2 credits a
1190
+ minute outbound, plus 8 a minute while an AI agent is on the call (10 in
1191
+ total for an API-placed call). Unanswered calls cost nothing. Destinations
1192
+ are US and Canadian numbers. Reads need the `calls:read` scope; `create`
1193
+ and `hangup` need `calls:write` and a live key.
1194
+
1195
+ > **Rolling out.** Voice is enabled workspace by workspace. Until it is on
1196
+ > for yours, every call method raises `Sendly::NotFoundError`
1197
+ > (`voice_not_enabled`).
1198
+
1199
+ ```ruby
1200
+ # Place a call. Returns at once with the call ringing; the agent greets the
1201
+ # callee when they answer and uses `context` for this call only.
1202
+ call = client.calls.create(
1203
+ to: "+15555550123",
1204
+ agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
1205
+ from: "+15555550188", # optional when you have one voice number
1206
+ context: "Confirm the 3pm appointment on Tuesday.",
1207
+ metadata: { "crmId" => "lead_8812" } # up to 20 string pairs, echoed everywhere
1208
+ )
1209
+ puts call.id
1210
+ puts call.status # "ringing"
1211
+ puts call.handled_by # "agent"
1212
+
1213
+ # Follow it. Agent-handled calls include a transcript once fetched by id.
1214
+ call = client.calls.get(call.id)
1215
+ puts call.status # "ringing" -> "active" -> "completed" (or no_answer, busy, ...)
1216
+ puts call.hangup_class # why it ended, e.g. "agent_agent_hangup"
1217
+ puts call.credits_charged
1218
+ call.transcript&.each { |line| puts "#{line.speaker}: #{line.text}" }
1219
+
1220
+ # List (newest first; limit 1-100, default 50)
1221
+ page = client.calls.list(status: "completed", direction: "outbound", agent_id: call.agent_id, limit: 20)
1222
+ page.each { |c| puts "#{c.to} #{c.duration_secs}s #{c.credits_charged} credits" }
1223
+ puts page.total
1224
+ puts page.has_more?
1225
+
1226
+ # End a call. Ringing -> "cancelled", active -> "completed"; a call that
1227
+ # has already ended comes back unchanged.
1228
+ client.calls.hangup(call.id)
1229
+
1230
+ # Recording. The URL is signed and valid for five minutes; it is nil until
1231
+ # the recording is ready. Ogg/Opus, dual channel on agent calls.
1232
+ rec = client.calls.recording(call.id)
1233
+ if rec.ready?
1234
+ File.binwrite("#{call.id}.ogg", Net::HTTP.get(URI(rec.url)))
1235
+ end
1236
+ ```
1237
+
1238
+ Call errors map onto the usual classes: `Sendly::InsufficientCreditsError`
1239
+ when the balance cannot cover one minute at the agent rate;
1240
+ `Sendly::NotFoundError` for `voice_not_enabled`, `outbound_calls_not_enabled`,
1241
+ `agent_not_found`, `number_not_found` and `call_not_found`;
1242
+ `Sendly::ValidationError` for `invalid_number`, `destination_not_supported`,
1243
+ `agent_required`, `invalid_metadata` and `from_number_required`;
1244
+ `Sendly::RateLimitError` for `daily_call_limit`; `Sendly::APIError` with
1245
+ `status_code` 428 for `e911_required` (register an emergency address for the
1246
+ number), 409 for `agent_disabled`, `no_voice_number` and `lines_busy` (retry
1247
+ shortly), or 403 for `live_key_required` and a key missing the scope; and
1248
+ `Sendly::ServerError` (a sibling of `APIError`, not a subclass) for 503
1249
+ `voice_unavailable` / `agents_unavailable` and 500 `voice_internal_error`.
1250
+ The full list is `Sendly::Call::ERROR_CODES`; the hangup vocabulary is
1251
+ `Sendly::Call::HANGUP_CLASSES`.
1252
+
1253
+ `call.started`, `call.completed` and `call.recording.ready` webhooks carry
1254
+ the same object in snake_case (`handled_by`, `hangup_class`, `billing`,
1255
+ `metadata`, ...); `Sendly::Call.new(event.raw_object)` reads it.
1256
+
1104
1257
  ## Error Handling
1105
1258
 
1106
1259
  ```ruby
@@ -55,7 +55,7 @@ module Sendly
55
55
  # @param key_id [String] API key ID
56
56
  # @return [Sendly::ApiKey]
57
57
  def api_key(key_id)
58
- response = @client.get("/account/keys/#{key_id}")
58
+ response = @client.get("/account/keys/#{URI.encode_www_form_component(key_id)}")
59
59
  ApiKey.new(response)
60
60
  end
61
61
 
@@ -64,7 +64,7 @@ module Sendly
64
64
  # @param key_id [String] API key ID
65
65
  # @return [Hash] Usage statistics
66
66
  def api_key_usage(key_id)
67
- @client.get("/account/keys/#{key_id}/usage")
67
+ @client.get("/account/keys/#{URI.encode_www_form_component(key_id)}/usage")
68
68
  end
69
69
 
70
70
  # Create a new API key
@@ -96,7 +96,7 @@ module Sendly
96
96
  body = {}
97
97
  body[:reason] = reason if reason
98
98
 
99
- @client.patch("/account/keys/#{key_id}/revoke", body)
99
+ @client.patch("/account/keys/#{URI.encode_www_form_component(key_id)}/revoke", body)
100
100
  end
101
101
 
102
102
  # Rotate an API key.
@@ -127,7 +127,7 @@ module Sendly
127
127
  body = {}
128
128
  body[:gracePeriodHours] = grace_period_hours unless grace_period_hours.nil?
129
129
 
130
- @client.post("/account/keys/#{key_id}/rotate", body)
130
+ @client.post("/account/keys/#{URI.encode_www_form_component(key_id)}/rotate", body)
131
131
  end
132
132
 
133
133
  def transfer_credits(target_organization_id:, amount:)