voiceml 0.7.1.1

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,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ require_relative 'errors'
8
+ require_relative 'version'
9
+
10
+ module VoiceML
11
+ # HTTP transport for the VoiceML REST API.
12
+ #
13
+ # - Auth: HTTP Basic with `account_sid` (Twilio-format `AC` + 32 hex) as the username and the
14
+ # per-tenant API key as the password. Drop-in compatible with the Twilio SDK constructor.
15
+ # - Wire format: requests are form-urlencoded by default (Twilio convention). The server also
16
+ # accepts JSON; pass `json: <hash>` to send JSON instead. Responses are always JSON.
17
+ # - Retries: 429 + 5xx are retried up to `max_retries` times with exponential backoff,
18
+ # honoring the `Retry-After` header when the server emits one.
19
+ # - Binary fetch: `fetch_bytes` follows the 302 -> S3 redirect that
20
+ # `GET /Recordings/{sid}.wav` issues when audio has been archived. Callers usually only
21
+ # care about the final bytes.
22
+ #
23
+ # @api private
24
+ class Transport
25
+ DEFAULT_BASE_URL = 'https://voiceml.voicetel.com'
26
+ DEFAULT_TIMEOUT = 30
27
+ DEFAULT_MAX_RETRIES = 2
28
+ RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
29
+
30
+ # Linear-time trailing-slash strip. Equivalent to `base_url.sub(%r{/+\z}, '')` but
31
+ # without the polynomial-backtracking shape CodeQL flags (`rb/polynomial-redos`).
32
+ def self.strip_trailing_slashes(s)
33
+ i = s.length
34
+ i -= 1 while i.positive? && s.getbyte(i - 1) == 47 # '/'
35
+ i == s.length ? s : s[0, i]
36
+ end
37
+
38
+ attr_reader :account_sid, :base_url, :max_retries, :timeout, :user_agent
39
+
40
+ def initialize(account_sid:, api_key:, base_url: DEFAULT_BASE_URL,
41
+ timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES,
42
+ user_agent: nil, http_client: nil)
43
+ raise ConfigurationError, 'account_sid is required' if account_sid.nil? || account_sid.empty?
44
+ raise ConfigurationError, 'api_key is required' if api_key.nil? || api_key.empty?
45
+ raise ConfigurationError, 'max_retries must be >= 0' if max_retries.negative?
46
+
47
+ @account_sid = account_sid
48
+ @api_key = api_key
49
+ @base_url = self.class.strip_trailing_slashes(base_url)
50
+ @timeout = timeout
51
+ @max_retries = max_retries
52
+ @user_agent = user_agent || "voiceml-ruby/#{VoiceML::VERSION}"
53
+ @uri_base = URI.parse(@base_url)
54
+ @conn_mutex = Mutex.new
55
+ @owns_client = http_client.nil?
56
+ @persistent = http_client
57
+ end
58
+
59
+ # Close the persistent connection, releasing the underlying socket.
60
+ # Safe to call multiple times. The transport remains usable — the next
61
+ # request will transparently open a fresh connection.
62
+ def close
63
+ @conn_mutex.synchronize { finish_connection } if @owns_client
64
+ end
65
+
66
+ # Perform a request. Pass `form:` for a form-urlencoded POST body, `json:` for a JSON body,
67
+ # or neither for a plain GET/DELETE. `params:` are query-string params.
68
+ #
69
+ # @return [Hash, Array, nil] the parsed JSON body (or `nil` for empty 2xx).
70
+ # @raise [VoiceML::ApiError] for non-2xx responses (subclasses by status family).
71
+ def request(method, path, params: nil, form: nil, json: nil)
72
+ uri = build_uri(path, params)
73
+
74
+ attempt = 0
75
+ loop do
76
+ response = perform_request(method, uri, form: form, json: json)
77
+
78
+ if RETRYABLE_STATUSES.include?(response.code.to_i) && attempt < @max_retries
79
+ sleep(backoff_delay(attempt, response))
80
+ attempt += 1
81
+ next
82
+ end
83
+
84
+ return parse_response(response)
85
+ rescue *transport_errors => e
86
+ raise ApiError.new("transport error after #{attempt + 1} attempts: #{e.message}",
87
+ status_code: 0) if attempt >= @max_retries
88
+
89
+ sleep(backoff_delay(attempt))
90
+ attempt += 1
91
+ end
92
+ end
93
+
94
+ # Fetch a binary payload (audio/wav recordings). Follows the single 302 -> presigned S3
95
+ # redirect that `GET /Recordings/{sid}.wav` issues when audio has been archived.
96
+ #
97
+ # @return [Array(Integer, String, Hash)] status code, response body bytes, header hash.
98
+ def fetch_bytes(path)
99
+ uri = build_uri(path, nil)
100
+ visited = []
101
+ loop do
102
+ raise ApiError.new('too many redirects', status_code: 0) if visited.length > 5
103
+
104
+ req = Net::HTTP::Get.new(uri)
105
+ apply_common_headers(req, auth: !visited.any? { |u| u.host != uri.host })
106
+ response = send_on_persistent(uri, req)
107
+
108
+ case response
109
+ when Net::HTTPRedirection
110
+ visited << uri
111
+ uri = URI.parse(response['location'])
112
+ next
113
+ when Net::HTTPSuccess
114
+ headers = response.each_header.to_h
115
+ return [response.code.to_i, response.body || '', headers]
116
+ else
117
+ raise_for_response(response)
118
+ end
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def transport_errors
125
+ [Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET,
126
+ Errno::EHOSTUNREACH, SocketError, EOFError]
127
+ end
128
+
129
+ def build_uri(path, params)
130
+ uri = if path.start_with?('http://', 'https://')
131
+ URI.parse(path)
132
+ else
133
+ URI.parse("#{@base_url}#{path}")
134
+ end
135
+ if params && !params.empty?
136
+ encoded = encode_query(params)
137
+ uri.query = encoded unless encoded.empty?
138
+ end
139
+ uri
140
+ end
141
+
142
+ def encode_query(params)
143
+ pairs = []
144
+ params.each do |k, v|
145
+ next if v.nil?
146
+
147
+ if v.is_a?(Array)
148
+ v.each { |entry| pairs << [k.to_s, format_scalar(entry)] }
149
+ else
150
+ pairs << [k.to_s, format_scalar(v)]
151
+ end
152
+ end
153
+ URI.encode_www_form(pairs)
154
+ end
155
+
156
+ def encode_form(form)
157
+ pairs = []
158
+ form.each do |k, v|
159
+ next if v.nil?
160
+
161
+ if v.is_a?(Array)
162
+ v.each { |entry| pairs << [k.to_s, format_scalar(entry)] }
163
+ else
164
+ pairs << [k.to_s, format_scalar(v)]
165
+ end
166
+ end
167
+ URI.encode_www_form(pairs)
168
+ end
169
+
170
+ def format_scalar(value)
171
+ case value
172
+ when true then 'true'
173
+ when false then 'false'
174
+ else value.to_s
175
+ end
176
+ end
177
+
178
+ def perform_request(method, uri, form:, json:)
179
+ req = build_net_request(method, uri)
180
+ apply_common_headers(req, auth: true)
181
+
182
+ if !json.nil?
183
+ req['Content-Type'] = 'application/json'
184
+ req.body = JSON.generate(json)
185
+ elsif !form.nil? && method.to_s.upcase != 'GET' && method.to_s.upcase != 'DELETE'
186
+ req['Content-Type'] = 'application/x-www-form-urlencoded'
187
+ req.body = encode_form(form)
188
+ end
189
+
190
+ send_on_persistent(uri, req)
191
+ end
192
+
193
+ def build_net_request(method, uri)
194
+ case method.to_s.upcase
195
+ when 'GET' then Net::HTTP::Get.new(uri)
196
+ when 'POST' then Net::HTTP::Post.new(uri)
197
+ when 'PUT' then Net::HTTP::Put.new(uri)
198
+ when 'DELETE' then Net::HTTP::Delete.new(uri)
199
+ when 'PATCH' then Net::HTTP::Patch.new(uri)
200
+ else
201
+ raise ArgumentError, "unsupported HTTP method: #{method}"
202
+ end
203
+ end
204
+
205
+ def apply_common_headers(req, auth:)
206
+ req['Accept'] = 'application/json'
207
+ req['User-Agent'] = @user_agent
208
+ req.basic_auth(@account_sid, @api_key) if auth
209
+ end
210
+
211
+ def send_on_persistent(uri, req)
212
+ conn = persistent_connection(uri)
213
+ conn.request(req)
214
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
215
+ @conn_mutex.synchronize { finish_connection }
216
+ persistent_connection(uri).request(req)
217
+ end
218
+
219
+ def persistent_connection(uri)
220
+ @conn_mutex.synchronize do
221
+ if @persistent&.started? &&
222
+ @persistent.address == uri.host &&
223
+ @persistent.port == uri.port
224
+ return @persistent
225
+ end
226
+ finish_connection
227
+ h = Net::HTTP.new(uri.host, uri.port)
228
+ h.use_ssl = uri.scheme == 'https'
229
+ h.open_timeout = @timeout
230
+ h.read_timeout = @timeout
231
+ h.keep_alive_timeout = 30
232
+ h.start
233
+ @persistent = h
234
+ end
235
+ end
236
+
237
+ def finish_connection
238
+ @persistent&.finish rescue nil
239
+ @persistent = nil
240
+ end
241
+
242
+ def parse_response(response)
243
+ status = response.code.to_i
244
+ return nil if status >= 200 && status < 300 && (response.body.nil? || response.body.empty?)
245
+
246
+ if status >= 200 && status < 300
247
+ begin
248
+ return JSON.parse(response.body)
249
+ rescue JSON::ParserError => e
250
+ raise ApiError.new("non-JSON success response (#{e.message}): #{response.body.to_s[0, 200]}",
251
+ status_code: status, body: response.body)
252
+ end
253
+ end
254
+
255
+ raise_for_response(response)
256
+ end
257
+
258
+ def raise_for_response(response)
259
+ status = response.code.to_i
260
+ raw_body = response.body
261
+ body = begin
262
+ raw_body && !raw_body.empty? ? JSON.parse(raw_body) : raw_body
263
+ rescue JSON::ParserError
264
+ raw_body
265
+ end
266
+
267
+ code = nil
268
+ message = "HTTP #{status}"
269
+ more_info = nil
270
+ if body.is_a?(Hash)
271
+ rc = body['code']
272
+ code = rc if rc.is_a?(Integer) || rc.is_a?(String)
273
+ m = body['message']
274
+ message = m if m.is_a?(String) && !m.empty?
275
+ mi = body['more_info']
276
+ more_info = mi if mi.is_a?(String) && !mi.empty?
277
+ end
278
+
279
+ raise VoiceML.error_from_response(status, message, code: code, body: body, more_info: more_info)
280
+ end
281
+
282
+ def backoff_delay(attempt, response = nil)
283
+ if response
284
+ ra = response['retry-after']
285
+ if ra
286
+ begin
287
+ return [Float(ra), 0.0].max
288
+ rescue ArgumentError, TypeError
289
+ # fall through
290
+ end
291
+ end
292
+ end
293
+ [8.0, 0.5 * (2**attempt)].min
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VoiceML
4
+ VERSION = '0.7.1.1'
5
+ end
data/lib/voiceml.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Official Ruby SDK for the VoiceML REST API.
4
+ #
5
+ # VoiceML is VoiceTel's outbound voice + AMD service with a Twilio-compatible REST surface
6
+ # (`https://voiceml.voicetel.com`). The wire shape, auth model, error codes, and pagination
7
+ # envelope all match Twilio's documented behaviour — so existing Twilio client patterns
8
+ # map across.
9
+ #
10
+ # @example
11
+ # require 'voiceml'
12
+ #
13
+ # client = VoiceML::Client.new(account_sid: 'AC...', api_key: '...')
14
+ # call = client.calls.create(
15
+ # to: '+18005551234',
16
+ # from: '+18005550000',
17
+ # url: 'https://example.com/twiml',
18
+ # machine_detection: 'DetectMessageEnd'
19
+ # )
20
+ # puts call.sid, call.status
21
+ module VoiceML
22
+ end
23
+
24
+ require_relative 'voiceml/version'
25
+ require_relative 'voiceml/errors'
26
+ require_relative 'voiceml/transport'
27
+ require_relative 'voiceml/client'
metadata ADDED
@@ -0,0 +1,118 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: voiceml
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.7.1.1
5
+ platform: ruby
6
+ authors:
7
+ - VoiceTel
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '13.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '13.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.13'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: webmock
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.23'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.23'
55
+ description: Twilio-compatible voice + AMD service from VoiceTel
56
+ email:
57
+ - support@voicetel.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - LICENSE
63
+ - README.md
64
+ - lib/voiceml.rb
65
+ - lib/voiceml/client.rb
66
+ - lib/voiceml/errors.rb
67
+ - lib/voiceml/models/applications.rb
68
+ - lib/voiceml/models/calls.rb
69
+ - lib/voiceml/models/common.rb
70
+ - lib/voiceml/models/conferences.rb
71
+ - lib/voiceml/models/diagnostics.rb
72
+ - lib/voiceml/models/incoming_phone_numbers.rb
73
+ - lib/voiceml/models/messages.rb
74
+ - lib/voiceml/models/payments.rb
75
+ - lib/voiceml/models/queues.rb
76
+ - lib/voiceml/models/recordings.rb
77
+ - lib/voiceml/models/siprec.rb
78
+ - lib/voiceml/models/streams.rb
79
+ - lib/voiceml/models/transcriptions.rb
80
+ - lib/voiceml/resources/applications.rb
81
+ - lib/voiceml/resources/base.rb
82
+ - lib/voiceml/resources/calls.rb
83
+ - lib/voiceml/resources/conferences.rb
84
+ - lib/voiceml/resources/diagnostics.rb
85
+ - lib/voiceml/resources/incoming_phone_numbers.rb
86
+ - lib/voiceml/resources/messages.rb
87
+ - lib/voiceml/resources/notifications.rb
88
+ - lib/voiceml/resources/queues.rb
89
+ - lib/voiceml/resources/recordings.rb
90
+ - lib/voiceml/transport.rb
91
+ - lib/voiceml/version.rb
92
+ homepage: https://voiceml.voicetel.com
93
+ licenses:
94
+ - MIT
95
+ metadata:
96
+ documentation_uri: https://voiceml.voicetel.com
97
+ source_code_uri: https://voiceml.voicetel.com
98
+ rubygems_mfa_required: 'true'
99
+ post_install_message:
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '3.0'
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubygems_version: 3.4.19
115
+ signing_key:
116
+ specification_version: 4
117
+ summary: Official Ruby SDK for the VoiceML REST API
118
+ test_files: []