voiceml 0.9.1 β†’ 0.9.2

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: f65ec54117bee65194bbf24028d00c20d820cee04cc9200472a42b329cd942f8
4
- data.tar.gz: 3e2104d7267e31948f3160b37e527cec42acf4a68cb366b4963435f71e55f8ca
3
+ metadata.gz: ce6f40c38ff553e748881a1b57f940e977cd24983da928988a7dcc77640cc32d
4
+ data.tar.gz: d6d26a2ab6ee10d9df572f24c6b667cb87cb03bb68ef40234fb05308db3d3789
5
5
  SHA512:
6
- metadata.gz: dbbe6c38b68861d43ed68d7d3ffa335fb9caf7582fa47b091ca37e29ed3fda725f3b492aab06501c7a61d686c2db1e123fa0fd75e5440ed407a4304646feb19a
7
- data.tar.gz: '09ced634a9e275031687a7d3647795a4c7a287928a8da4ffe5f61babd10c86eee840890e5233552e64ddf4aa68a50319ef42cd2a37afe68975d87cddb16dbe69'
6
+ metadata.gz: 28fe625cbe02ccd47894507e8c28d8eecd06f0caae4aea040f5e21af82980072e39a74aa7cb788fc885f9b24e58239099a87dac639a79b9f3a3c248769e0a652
7
+ data.tar.gz: 85fd838a671b848768a791d590fbcdefa40c49e89d80b8f2c2c98f475af7035489a727ca3d1ecd7e5988296a5a62f32e3784344fc9fcc11f24beec16d5d9619e
data/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # πŸ“ž VoiceML Ruby SDK
2
2
 
3
- The official Ruby client for the [VoiceML REST API](https://voicetel.com/docs/api/v0.8/voiceml/) β€” Twilio-compatible outbound voice and answering-machine-detection from VoiceTel, with idiomatic snake_case kwargs, structured errors, and a Twilio-compatible wire format.
3
+ The official Ruby client for the [VoiceML REST API](https://voicetel.com/docs/api/v0.9/voiceml/) β€” Twilio-compatible outbound voice and answering-machine-detection from VoiceTel, with idiomatic snake_case kwargs, structured errors, and a Twilio-compatible wire format.
4
4
 
5
- ![Version](https://img.shields.io/badge/version-0.9.1-blue)
5
+ ![Version](https://img.shields.io/badge/version-0.9.2-blue)
6
6
  ![Ruby](https://img.shields.io/badge/ruby-3.0%2B-red)
7
7
  ![License](https://img.shields.io/badge/license-MIT-green)
8
- ![Tests](https://img.shields.io/badge/tests-65%20specs-brightgreen)
8
+ ![Tests](https://img.shields.io/badge/tests-188%20examples-brightgreen)
9
9
  ![Gem](https://img.shields.io/badge/gem-voiceml-blue)
10
10
 
11
11
  ## πŸ“š Table of Contents
@@ -49,10 +49,15 @@ The official Ruby client for the [VoiceML REST API](https://voicetel.com/docs/ap
49
49
  - **Notifications** β€” fetch, list.
50
50
  - **SIP** β€” SIP Trunking: Domains (CRUD), CredentialLists + Credentials (CRUD), IpAccessControlLists + IpAddresses (CRUD), Domain↔ACL/CredentialList mappings (historical, Auth/Calls, Auth/Registrations namespaces).
51
51
  - **Routes V2** β€” Twilio Inbound Processing Region API: `client.routes_v2.sip_domains.fetch(name)` / `update(name, voice_region:, friendly_name:)`.
52
+ - **Messaging Service** β€” `client.messaging_v1.services` CRUD (`MG...` services), routed automatically at the `messaging.voicetel.com` product host.
53
+ - **Pricing** β€” read-only `client.pricing.v1` / `client.pricing.v2` for Voice, Messaging, PhoneNumbers, and Trunking country + number lookups.
52
54
  - **Diagnostics** β€” `/health` deep probe, OpenAPI spec.
53
55
 
56
+ ### 🌐 Per-product hosts
57
+ VoiceML mirrors Twilio's product-per-subdomain model. The whole `conversations_v1` group is routed to `conversations.voicetel.com` and `messaging_v1` to `messaging.voicetel.com`, while everything else stays on the default `voiceml.voicetel.com`. These hosts are derived automatically from `base_url`; a non-`voicetel.com` base (e.g. a self-hosted instance) keeps every product on that single host. Override per product with `messaging_base_url:` / `conversations_base_url:` on the client constructor.
58
+
54
59
  ### πŸ§ͺ Tested
55
- - **81 specs** with mocked HTTP layer (`webmock`) covering every resource and pagination edge cases β€” spec drift gets caught at parse time.
60
+ - **188 examples** with mocked HTTP layer (`webmock`) covering every resource and pagination edge cases β€” spec drift gets caught at parse time.
56
61
  - Integration smoke spec gated by env vars β€” safe for CI.
57
62
 
58
63
  ### πŸ“¦ Clean Distribution
@@ -110,7 +115,7 @@ client = VoiceML::Client.new(account_sid: 'AC...', api_key: '...')
110
115
  client.diagnostics.health # uses your AccountSid + key on every call
111
116
  ```
112
117
 
113
- > Don't have credentials yet? See **[voicetel.com/docs/api/v0.8/voiceml/](https://voicetel.com/docs/api/v0.8/voiceml/)** for issuance and rotation.
118
+ > Don't have credentials yet? See **[voicetel.com/docs/api/v0.9/voiceml/](https://voicetel.com/docs/api/v0.9/voiceml/)** for issuance and rotation.
114
119
 
115
120
  ## πŸ—ΊοΈ Resource Reference
116
121
 
@@ -124,6 +129,8 @@ client.diagnostics.health # uses your AccountSid + key on every call
124
129
  | `client.messages` | create, fetch, list, update, delete | To/From/DateSent filters; Body redaction; Status=canceled |
125
130
  | `client.incoming_phone_numbers` | list, fetch, update | |
126
131
  | `client.notifications` | fetch, list | |
132
+ | `client.messaging_v1.services` | create, list, fetch, update, delete | Messaging Service (`MG...`); routed at `messaging.voicetel.com` |
133
+ | `client.pricing.v1` / `.v2` | countries.list / fetch, numbers.fetch | Voice, Messaging, PhoneNumbers, Trunking (read-only, default host) |
127
134
  | `client.diagnostics` | `/health`, OpenAPI spec | |
128
135
 
129
136
  Methods accept idiomatic snake_case keyword arguments β€” they're translated to Twilio's PascalCase wire field names internally:
@@ -245,7 +252,7 @@ gem build voiceml.gemspec
245
252
 
246
253
  ## πŸ“– API Documentation
247
254
 
248
- - **Reference docs:** [voicetel.com/docs/api/v0.8/voiceml/](https://voicetel.com/docs/api/v0.8/voiceml/)
255
+ - **Reference docs:** [voicetel.com/docs/api/v0.9/voiceml/](https://voicetel.com/docs/api/v0.9/voiceml/)
249
256
  - **Validator:** [voicetel.com/voiceml/validator/](https://voicetel.com/voiceml/validator/)
250
257
  - **SDK catalogue:** [voicetel.com/docs/voiceml-sdks/](https://voicetel.com/docs/voiceml-sdks/)
251
258
  - **YARD comments:** every resource method carries `@param` / `@return` docs β€” `yard doc lib` builds them locally.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'transport'
4
+ require_relative 'hosts'
4
5
  require_relative 'resources/calls'
5
6
  require_relative 'resources/conferences'
6
7
  require_relative 'resources/queues'
@@ -15,6 +16,8 @@ require_relative 'resources/routes_v2'
15
16
  require_relative 'resources/voice_v1'
16
17
  require_relative 'resources/conversations_v1'
17
18
  require_relative 'resources/assistants_v1'
19
+ require_relative 'resources/messaging_v1'
20
+ require_relative 'resources/pricing'
18
21
 
19
22
  module VoiceML
20
23
  # Synchronous client for the VoiceML REST API.
@@ -34,7 +37,8 @@ module VoiceML
34
37
  class Client
35
38
  attr_reader :calls, :conferences, :queues, :applications, :recordings,
36
39
  :incoming_phone_numbers, :notifications, :diagnostics, :messages,
37
- :sip, :routes_v2, :voice_v1, :conversations_v1, :assistants_v1
40
+ :sip, :routes_v2, :voice_v1, :conversations_v1, :assistants_v1,
41
+ :messaging_v1, :pricing
38
42
 
39
43
  # @param account_sid [String] Twilio-format AccountSid (`AC` + 32 hex).
40
44
  # @param api_key [String, nil] per-tenant API key. Pass either `api_key:` or the
@@ -46,11 +50,16 @@ module VoiceML
46
50
  # @param max_retries [Integer] retry attempts for 429/5xx and transport errors. Defaults to 2.
47
51
  # @param user_agent [String, nil] override the `User-Agent` header. Defaults to
48
52
  # `"voiceml-ruby/#{VERSION}"`.
53
+ # @param messaging_base_url [String, nil] override the host for `messaging_v1`. Defaults to
54
+ # the `messaging.*.voicetel.com` subdomain derived from `base_url` (else `base_url` itself).
55
+ # @param conversations_base_url [String, nil] override the host for `conversations_v1`. Defaults
56
+ # to the `conversations.*.voicetel.com` subdomain derived from `base_url` (else `base_url`).
49
57
  def initialize(account_sid:, api_key: nil, auth_token: nil,
50
58
  base_url: Transport::DEFAULT_BASE_URL,
51
59
  timeout: Transport::DEFAULT_TIMEOUT,
52
60
  max_retries: Transport::DEFAULT_MAX_RETRIES,
53
- user_agent: nil, http_client: nil)
61
+ user_agent: nil, http_client: nil,
62
+ messaging_base_url: nil, conversations_base_url: nil)
54
63
  if !api_key.nil? && !auth_token.nil?
55
64
  raise ArgumentError, 'pass either api_key: or auth_token:, not both'
56
65
  end
@@ -67,6 +76,16 @@ module VoiceML
67
76
  http_client: http_client
68
77
  )
69
78
 
79
+ # VoiceML mirrors Twilio's product-per-subdomain model: the whole Conversations group
80
+ # rides `conversations.voicetel.com` and Messaging Service rides `messaging.voicetel.com`,
81
+ # while everything else stays on the default host. See VoiceML::Hosts.
82
+ _default, messaging_host, conversations_host =
83
+ Hosts.resolve_product_base_urls(base_url,
84
+ messaging_base_url: messaging_base_url,
85
+ conversations_base_url: conversations_base_url)
86
+ messaging_transport = ScopedTransport.new(@transport, messaging_host)
87
+ conversations_transport = ScopedTransport.new(@transport, conversations_host)
88
+
70
89
  @calls = CallsResource.new(@transport)
71
90
  @conferences = ConferencesResource.new(@transport)
72
91
  @queues = QueuesResource.new(@transport)
@@ -79,8 +98,10 @@ module VoiceML
79
98
  @sip = SipResource.new(@transport)
80
99
  @routes_v2 = RoutesV2Resource.new(@transport)
81
100
  @voice_v1 = VoiceV1Resource.new(@transport)
82
- @conversations_v1 = ConversationsV1Resource.new(@transport)
101
+ @conversations_v1 = ConversationsV1Resource.new(conversations_transport)
83
102
  @assistants_v1 = AssistantsV1Resource.new(@transport)
103
+ @messaging_v1 = MessagingV1Resource.new(messaging_transport)
104
+ @pricing = PricingResource.new(@transport)
84
105
  end
85
106
 
86
107
  def account_sid
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ require_relative 'transport'
6
+
7
+ module VoiceML
8
+ # Per-product host resolution for the VoiceML API.
9
+ #
10
+ # Twilio splits its products across dedicated subdomains (`api.twilio.com`,
11
+ # `conversations.twilio.com`, `messaging.twilio.com`, ...). VoiceML mirrors that
12
+ # shape on `voicetel.com`: the Conversations product answers on
13
+ # `conversations.voicetel.com` and the Messaging Service product on
14
+ # `messaging.voicetel.com`, while everything else stays on the default
15
+ # `voiceml.voicetel.com` host. Conversation Service and Messaging Service share
16
+ # the identical `/v1/Services` path shape, so the *host* is what disambiguates
17
+ # them on the wire.
18
+ #
19
+ # Given the configured `base_url` this module derives the two product hosts by
20
+ # swapping the leftmost `voiceml` label β€” but only for recognised
21
+ # `*.voicetel.com` hosts. For any other base URL (a self-hosted callBroadcast
22
+ # instance, a test stub, a regional override) the product hosts fall back to the
23
+ # configured host unchanged, so a single-host deployment keeps working. A caller
24
+ # who needs Messaging Service against a custom host points `messaging_base_url`
25
+ # (or `conversations_base_url`) at their own subdomain explicitly.
26
+ module Hosts
27
+ module_function
28
+
29
+ # Swap the `voiceml` label of a `*.voicetel.com` host for `product`.
30
+ #
31
+ # Returns `base_url` unchanged when the host is not a `voiceml.*.voicetel.com`
32
+ # style host (e.g. a self-hosted instance), so single-host deployments keep
33
+ # working without special-casing.
34
+ def derive_product_host(base_url, product)
35
+ uri = URI.parse(base_url)
36
+ host = uri.host
37
+ return base_url if host.nil? || !host.end_with?('.voicetel.com')
38
+
39
+ labels = host.split('.')
40
+ idx = labels.index('voiceml')
41
+ return base_url if idx.nil?
42
+
43
+ labels[idx] = product
44
+ uri.host = labels.join('.')
45
+ uri.query = nil
46
+ uri.fragment = nil
47
+ uri.to_s
48
+ end
49
+
50
+ # Return `[default, messaging, conversations]` base URLs.
51
+ #
52
+ # Explicit overrides win; otherwise each product host is derived from
53
+ # `base_url` (see module docstring). All three are returned without a
54
+ # trailing slash.
55
+ def resolve_product_base_urls(base_url, messaging_base_url: nil, conversations_base_url: nil)
56
+ default = Transport.strip_trailing_slashes(base_url)
57
+ messaging = Transport.strip_trailing_slashes(messaging_base_url || derive_product_host(default, 'messaging'))
58
+ conversations = Transport.strip_trailing_slashes(conversations_base_url || derive_product_host(default, 'conversations'))
59
+ [default, messaging, conversations]
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'common'
4
+ require_relative 'voice_v1' # V1Pageable lives here; Messaging v1 reuses the same `meta` envelope
5
+
6
+ module VoiceML
7
+ # Twilio Messaging v1 (messaging.twilio.com/v1) resources.
8
+ #
9
+ # A Messaging Service (`MG...`) shares the `/v1/Services` path shape with the
10
+ # Conversations Service (`IS...`); the two are disambiguated on the wire by host
11
+ # (`messaging.voicetel.com` vs `conversations.voicetel.com`). This SDK routes
12
+ # `client.messaging_v1.*` at the messaging host automatically β€” see VoiceML::Hosts.
13
+
14
+ # MessagingService β€” `MG...`. The feature-toggle fields are accept-and-echo on
15
+ # VoiceML; the service's operative role is gating scheduled sends.
16
+ class MessagingService
17
+ ATTRIBUTES = %w[
18
+ sid account_sid friendly_name date_created date_updated
19
+ inbound_request_url inbound_method fallback_url fallback_method status_callback
20
+ sticky_sender mms_converter smart_encoding scan_message_content
21
+ fallback_to_long_code area_code_geomatch synchronous_validation validity_period
22
+ url usecase use_inbound_webhook_on_number
23
+ ].freeze
24
+ attr_reader(*ATTRIBUTES.map(&:to_sym))
25
+ def initialize(attrs = {})
26
+ ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs[f] || attrs[f.to_sym]) }
27
+ end
28
+ def self.from_hash(h); h.nil? ? nil : new(h); end
29
+ end
30
+
31
+ # List envelope for `GET /v1/Services` on the messaging host.
32
+ class MessagingServiceList
33
+ include V1Pageable
34
+ attr_reader :services
35
+ def initialize(hash = {})
36
+ assign_meta_fields(hash)
37
+ @services = (hash['services'] || []).map { |h| MessagingService.from_hash(h) }
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'common'
4
+ require_relative 'voice_v1' # V1Pageable lives here; PricingCountriesList reuses the `meta` envelope
5
+
6
+ module VoiceML
7
+ # Twilio Pricing (pricing.twilio.com) v1/v2 response models.
8
+ #
9
+ # VoiceML has no dedicated pricing subdomain, so these ride the default host
10
+ # (`voiceml.voicetel.com`) under `/v1` and `/v2`. All operations are read-only GETs.
11
+ # Every field is permissive/nullable β€” VoiceML is NANP-only, so a `Countries` list
12
+ # carries exactly one entry and a `Numbers` fetch 404s for non-NANP destinations.
13
+
14
+ # --- Price leaves ---------------------------------------------------------
15
+
16
+ class PricingInboundCallPrice
17
+ ATTRIBUTES = %w[base_price current_price number_type].freeze
18
+ attr_reader(*ATTRIBUTES.map(&:to_sym))
19
+ def initialize(attrs = {})
20
+ ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs[f] || attrs[f.to_sym]) }
21
+ end
22
+ def self.from_hash(h); h.nil? ? nil : new(h); end
23
+ end
24
+
25
+ class PricingOutboundCallPrice
26
+ ATTRIBUTES = %w[base_price current_price].freeze
27
+ attr_reader(*ATTRIBUTES.map(&:to_sym))
28
+ def initialize(attrs = {})
29
+ ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs[f] || attrs[f.to_sym]) }
30
+ end
31
+ def self.from_hash(h); h.nil? ? nil : new(h); end
32
+ end
33
+
34
+ class PricingOutboundCallPriceWithOrigin
35
+ attr_reader :origination_prefixes, :base_price, :current_price
36
+ def initialize(attrs = {})
37
+ @origination_prefixes = attrs['origination_prefixes'] || attrs[:origination_prefixes] || []
38
+ @base_price = attrs['base_price'] || attrs[:base_price]
39
+ @current_price = attrs['current_price'] || attrs[:current_price]
40
+ end
41
+ def self.from_hash(h); h.nil? ? nil : new(h); end
42
+ end
43
+
44
+ class PricingOutboundPrefixPrice
45
+ attr_reader :prefixes, :base_price, :current_price, :friendly_name
46
+ def initialize(attrs = {})
47
+ @prefixes = attrs['prefixes'] || attrs[:prefixes] || []
48
+ @base_price = attrs['base_price'] || attrs[:base_price]
49
+ @current_price = attrs['current_price'] || attrs[:current_price]
50
+ @friendly_name = attrs['friendly_name'] || attrs[:friendly_name]
51
+ end
52
+ def self.from_hash(h); h.nil? ? nil : new(h); end
53
+ end
54
+
55
+ class PricingOutboundPrefixPriceWithOrigin
56
+ attr_reader :origination_prefixes, :destination_prefixes, :base_price, :current_price, :friendly_name
57
+ def initialize(attrs = {})
58
+ @origination_prefixes = attrs['origination_prefixes'] || attrs[:origination_prefixes] || []
59
+ @destination_prefixes = attrs['destination_prefixes'] || attrs[:destination_prefixes] || []
60
+ @base_price = attrs['base_price'] || attrs[:base_price]
61
+ @current_price = attrs['current_price'] || attrs[:current_price]
62
+ @friendly_name = attrs['friendly_name'] || attrs[:friendly_name]
63
+ end
64
+ def self.from_hash(h); h.nil? ? nil : new(h); end
65
+ end
66
+
67
+ class PricingOutboundSMSPrice
68
+ attr_reader :carrier, :mcc, :mnc, :prices
69
+ def initialize(attrs = {})
70
+ @carrier = attrs['carrier'] || attrs[:carrier]
71
+ @mcc = attrs['mcc'] || attrs[:mcc]
72
+ @mnc = attrs['mnc'] || attrs[:mnc]
73
+ @prices = (attrs['prices'] || attrs[:prices] || []).map { |h| PricingInboundCallPrice.from_hash(h) }
74
+ end
75
+ def self.from_hash(h); h.nil? ? nil : new(h); end
76
+ end
77
+
78
+ class PricingPhoneNumberPrice
79
+ ATTRIBUTES = %w[number_type base_price current_price].freeze
80
+ attr_reader(*ATTRIBUTES.map(&:to_sym))
81
+ def initialize(attrs = {})
82
+ ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs[f] || attrs[f.to_sym]) }
83
+ end
84
+ def self.from_hash(h); h.nil? ? nil : new(h); end
85
+ end
86
+
87
+ # --- Countries list envelope ----------------------------------------------
88
+
89
+ class PricingCountryRef
90
+ ATTRIBUTES = %w[country iso_country url].freeze
91
+ attr_reader(*ATTRIBUTES.map(&:to_sym))
92
+ def initialize(attrs = {})
93
+ ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs[f] || attrs[f.to_sym]) }
94
+ end
95
+ def self.from_hash(h); h.nil? ? nil : new(h); end
96
+ end
97
+
98
+ class PricingCountriesList
99
+ include V1Pageable
100
+ attr_reader :countries
101
+ def initialize(hash = {})
102
+ assign_meta_fields(hash)
103
+ @countries = (hash['countries'] || []).map { |h| PricingCountryRef.from_hash(h) }
104
+ end
105
+ end
106
+
107
+ # --- Pricing v1 country / number bodies -----------------------------------
108
+
109
+ class PricingVoiceCountry
110
+ attr_reader :country, :iso_country, :outbound_prefix_prices, :inbound_call_prices, :price_unit, :url
111
+ def initialize(attrs = {})
112
+ @country = attrs['country'] || attrs[:country]
113
+ @iso_country = attrs['iso_country'] || attrs[:iso_country]
114
+ @outbound_prefix_prices =
115
+ (attrs['outbound_prefix_prices'] || []).map { |h| PricingOutboundPrefixPrice.from_hash(h) }
116
+ @inbound_call_prices =
117
+ (attrs['inbound_call_prices'] || []).map { |h| PricingInboundCallPrice.from_hash(h) }
118
+ @price_unit = attrs['price_unit'] || attrs[:price_unit]
119
+ @url = attrs['url'] || attrs[:url]
120
+ end
121
+ def self.from_hash(h); h.nil? ? nil : new(h); end
122
+ end
123
+
124
+ class PricingVoiceNumber
125
+ attr_reader :number, :country, :iso_country, :outbound_call_price, :inbound_call_price, :price_unit, :url
126
+ def initialize(attrs = {})
127
+ @number = attrs['number'] || attrs[:number]
128
+ @country = attrs['country'] || attrs[:country]
129
+ @iso_country = attrs['iso_country'] || attrs[:iso_country]
130
+ @outbound_call_price = PricingOutboundCallPrice.from_hash(attrs['outbound_call_price'])
131
+ @inbound_call_price = PricingInboundCallPrice.from_hash(attrs['inbound_call_price'])
132
+ @price_unit = attrs['price_unit'] || attrs[:price_unit]
133
+ @url = attrs['url'] || attrs[:url]
134
+ end
135
+ def self.from_hash(h); h.nil? ? nil : new(h); end
136
+ end
137
+
138
+ class PricingMessagingCountry
139
+ attr_reader :country, :iso_country, :outbound_sms_prices, :inbound_sms_prices, :price_unit, :url
140
+ def initialize(attrs = {})
141
+ @country = attrs['country'] || attrs[:country]
142
+ @iso_country = attrs['iso_country'] || attrs[:iso_country]
143
+ @outbound_sms_prices =
144
+ (attrs['outbound_sms_prices'] || []).map { |h| PricingOutboundSMSPrice.from_hash(h) }
145
+ @inbound_sms_prices =
146
+ (attrs['inbound_sms_prices'] || []).map { |h| PricingInboundCallPrice.from_hash(h) }
147
+ @price_unit = attrs['price_unit'] || attrs[:price_unit]
148
+ @url = attrs['url'] || attrs[:url]
149
+ end
150
+ def self.from_hash(h); h.nil? ? nil : new(h); end
151
+ end
152
+
153
+ class PricingPhoneNumberCountry
154
+ attr_reader :country, :iso_country, :phone_number_prices, :price_unit, :url
155
+ def initialize(attrs = {})
156
+ @country = attrs['country'] || attrs[:country]
157
+ @iso_country = attrs['iso_country'] || attrs[:iso_country]
158
+ @phone_number_prices =
159
+ (attrs['phone_number_prices'] || []).map { |h| PricingPhoneNumberPrice.from_hash(h) }
160
+ @price_unit = attrs['price_unit'] || attrs[:price_unit]
161
+ @url = attrs['url'] || attrs[:url]
162
+ end
163
+ def self.from_hash(h); h.nil? ? nil : new(h); end
164
+ end
165
+
166
+ # --- Pricing v2 country / number bodies -----------------------------------
167
+
168
+ class PricingVoiceCountryV2
169
+ attr_reader :country, :iso_country, :outbound_prefix_prices, :inbound_call_prices, :price_unit, :url
170
+ def initialize(attrs = {})
171
+ @country = attrs['country'] || attrs[:country]
172
+ @iso_country = attrs['iso_country'] || attrs[:iso_country]
173
+ @outbound_prefix_prices =
174
+ (attrs['outbound_prefix_prices'] || []).map { |h| PricingOutboundPrefixPriceWithOrigin.from_hash(h) }
175
+ @inbound_call_prices =
176
+ (attrs['inbound_call_prices'] || []).map { |h| PricingInboundCallPrice.from_hash(h) }
177
+ @price_unit = attrs['price_unit'] || attrs[:price_unit]
178
+ @url = attrs['url'] || attrs[:url]
179
+ end
180
+ def self.from_hash(h); h.nil? ? nil : new(h); end
181
+ end
182
+
183
+ class PricingVoiceNumberV2
184
+ attr_reader :destination_number, :origination_number, :country, :iso_country,
185
+ :outbound_call_prices, :inbound_call_price, :price_unit, :url
186
+ def initialize(attrs = {})
187
+ @destination_number = attrs['destination_number'] || attrs[:destination_number]
188
+ @origination_number = attrs['origination_number'] || attrs[:origination_number]
189
+ @country = attrs['country'] || attrs[:country]
190
+ @iso_country = attrs['iso_country'] || attrs[:iso_country]
191
+ @outbound_call_prices =
192
+ (attrs['outbound_call_prices'] || []).map { |h| PricingOutboundCallPriceWithOrigin.from_hash(h) }
193
+ @inbound_call_price = PricingInboundCallPrice.from_hash(attrs['inbound_call_price'])
194
+ @price_unit = attrs['price_unit'] || attrs[:price_unit]
195
+ @url = attrs['url'] || attrs[:url]
196
+ end
197
+ def self.from_hash(h); h.nil? ? nil : new(h); end
198
+ end
199
+
200
+ class PricingTrunkingCountry
201
+ attr_reader :country, :iso_country, :terminating_prefix_prices, :originating_call_prices, :price_unit, :url
202
+ def initialize(attrs = {})
203
+ @country = attrs['country'] || attrs[:country]
204
+ @iso_country = attrs['iso_country'] || attrs[:iso_country]
205
+ @terminating_prefix_prices =
206
+ (attrs['terminating_prefix_prices'] || []).map { |h| PricingOutboundPrefixPriceWithOrigin.from_hash(h) }
207
+ @originating_call_prices =
208
+ (attrs['originating_call_prices'] || []).map { |h| PricingInboundCallPrice.from_hash(h) }
209
+ @price_unit = attrs['price_unit'] || attrs[:price_unit]
210
+ @url = attrs['url'] || attrs[:url]
211
+ end
212
+ def self.from_hash(h); h.nil? ? nil : new(h); end
213
+ end
214
+
215
+ class PricingTrunkingNumber
216
+ attr_reader :destination_number, :origination_number, :country, :iso_country,
217
+ :terminating_prefix_prices, :originating_call_price, :price_unit, :url
218
+ def initialize(attrs = {})
219
+ @destination_number = attrs['destination_number'] || attrs[:destination_number]
220
+ @origination_number = attrs['origination_number'] || attrs[:origination_number]
221
+ @country = attrs['country'] || attrs[:country]
222
+ @iso_country = attrs['iso_country'] || attrs[:iso_country]
223
+ @terminating_prefix_prices =
224
+ (attrs['terminating_prefix_prices'] || []).map { |h| PricingOutboundPrefixPriceWithOrigin.from_hash(h) }
225
+ @originating_call_price = PricingInboundCallPrice.from_hash(attrs['originating_call_price'])
226
+ @price_unit = attrs['price_unit'] || attrs[:price_unit]
227
+ @url = attrs['url'] || attrs[:url]
228
+ end
229
+ def self.from_hash(h); h.nil? ? nil : new(h); end
230
+ end
231
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../models/messaging_v1'
4
+
5
+ module VoiceML
6
+ # `client.messaging_v1` β€” Twilio Messaging v1 (messaging.twilio.com/v1).
7
+ # The whole group is routed at the messaging host (`messaging.voicetel.com`) by the
8
+ # client, which is what disambiguates a Messaging Service (`MG...`) from a
9
+ # Conversation Service (`IS...`) β€” they share the `/v1/Services` path shape.
10
+ class MessagingV1Resource
11
+ attr_reader :services
12
+
13
+ def initialize(transport)
14
+ @services = MessagingV1ServicesResource.new(transport)
15
+ end
16
+ end
17
+
18
+ # Operations on `/v1/Services` at the messaging host.
19
+ #
20
+ # `create` / `list` / `fetch` / `delete` reuse the shared path; `update`
21
+ # (`POST /v1/Services/{sid}`) is unique to Messaging Service.
22
+ class MessagingV1ServicesResource
23
+ SERVICE_FIELDS = {
24
+ 'FriendlyName' => :friendly_name,
25
+ 'InboundRequestUrl' => :inbound_request_url,
26
+ 'InboundMethod' => :inbound_method,
27
+ 'FallbackUrl' => :fallback_url,
28
+ 'FallbackMethod' => :fallback_method,
29
+ 'StatusCallback' => :status_callback,
30
+ 'StickySender' => :sticky_sender,
31
+ 'MmsConverter' => :mms_converter,
32
+ 'SmartEncoding' => :smart_encoding,
33
+ 'ScanMessageContent' => :scan_message_content,
34
+ 'FallbackToLongCode' => :fallback_to_long_code,
35
+ 'AreaCodeGeomatch' => :area_code_geomatch,
36
+ 'SynchronousValidation' => :synchronous_validation,
37
+ 'ValidityPeriod' => :validity_period,
38
+ 'Usecase' => :usecase,
39
+ 'UseInboundWebhookOnNumber' => :use_inbound_webhook_on_number
40
+ }.freeze
41
+
42
+ def initialize(transport)
43
+ @transport = transport
44
+ end
45
+
46
+ def create(friendly_name:, **kwargs)
47
+ kwargs[:friendly_name] = friendly_name
48
+ MessagingService.from_hash(
49
+ @transport.request(:post, '/v1/Services', form: build_form(kwargs))
50
+ )
51
+ end
52
+
53
+ def list(page_size: nil)
54
+ params = {}
55
+ params['PageSize'] = page_size unless page_size.nil?
56
+ MessagingServiceList.new(@transport.request(:get, '/v1/Services', params: params))
57
+ end
58
+
59
+ def fetch(sid)
60
+ MessagingService.from_hash(@transport.request(:get, "/v1/Services/#{sid}"))
61
+ end
62
+
63
+ def update(sid, **kwargs)
64
+ MessagingService.from_hash(
65
+ @transport.request(:post, "/v1/Services/#{sid}", form: build_form(kwargs))
66
+ )
67
+ end
68
+
69
+ def delete(sid)
70
+ @transport.request(:delete, "/v1/Services/#{sid}")
71
+ nil
72
+ end
73
+
74
+ private
75
+
76
+ def build_form(kwargs)
77
+ out = {}
78
+ SERVICE_FIELDS.each do |wire, k|
79
+ value = kwargs[k]
80
+ next if value.nil?
81
+
82
+ out[wire] = value
83
+ end
84
+ out
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ require_relative '../models/pricing'
6
+
7
+ module VoiceML
8
+ # `client.pricing` β€” Twilio Pricing v1/v2 (pricing.twilio.com) surface.
9
+ #
10
+ # Read-only. Served on the default host (VoiceML has no pricing subdomain). Layout:
11
+ #
12
+ # client.pricing.v1.voice.countries.list / fetch
13
+ # client.pricing.v1.voice.numbers.fetch
14
+ # client.pricing.v1.messaging.countries.list / fetch
15
+ # client.pricing.v1.phone_numbers.countries.list / fetch
16
+ # client.pricing.v2.voice.countries.list / fetch
17
+ # client.pricing.v2.voice.numbers.fetch
18
+ # client.pricing.v2.trunking.countries.list / fetch
19
+ # client.pricing.v2.trunking.numbers.fetch
20
+ #
21
+ # Every `countries.list` returns the shared PricingCountriesList envelope; `fetch`
22
+ # returns the product-specific country/number body. Number path segments are
23
+ # URL-encoded (E.164 `+` -> `%2B`).
24
+ class PricingResource
25
+ attr_reader :v1, :v2
26
+
27
+ def initialize(transport)
28
+ @v1 = PricingV1Resource.new(transport)
29
+ @v2 = PricingV2Resource.new(transport)
30
+ end
31
+ end
32
+
33
+ # A pricing product group exposing `.countries` and optionally `.numbers`.
34
+ class PricingProduct
35
+ attr_reader :countries, :numbers
36
+
37
+ def initialize(countries, numbers = nil)
38
+ @countries = countries
39
+ @numbers = numbers
40
+ end
41
+ end
42
+
43
+ # `.../Countries` list + per-country fetch. `model` is the fetch body class.
44
+ class PricingCountriesResource
45
+ def initialize(transport, base_path, model)
46
+ @transport = transport
47
+ @base = base_path
48
+ @model = model
49
+ end
50
+
51
+ def list(page_size: nil)
52
+ params = {}
53
+ params['PageSize'] = page_size unless page_size.nil?
54
+ PricingCountriesList.new(@transport.request(:get, @base, params: params))
55
+ end
56
+
57
+ def fetch(iso_country)
58
+ @model.from_hash(@transport.request(:get, "#{@base}/#{iso_country}"))
59
+ end
60
+ end
61
+
62
+ # `/v1/Voice/Numbers/{Number}` β€” single-number fetch.
63
+ class PricingV1VoiceNumbersResource
64
+ def initialize(transport)
65
+ @transport = transport
66
+ end
67
+
68
+ def fetch(number)
69
+ PricingVoiceNumber.from_hash(
70
+ @transport.request(:get, "/v1/Voice/Numbers/#{URI.encode_www_form_component(number)}")
71
+ )
72
+ end
73
+ end
74
+
75
+ # `/v2/Voice/Numbers/{Destination}` β€” origin-aware single-number fetch.
76
+ class PricingV2VoiceNumbersResource
77
+ def initialize(transport)
78
+ @transport = transport
79
+ end
80
+
81
+ def fetch(destination_number, origination_number: nil)
82
+ params = {}
83
+ params['OriginationNumber'] = origination_number unless origination_number.nil?
84
+ PricingVoiceNumberV2.from_hash(
85
+ @transport.request(:get, "/v2/Voice/Numbers/#{URI.encode_www_form_component(destination_number)}",
86
+ params: params)
87
+ )
88
+ end
89
+ end
90
+
91
+ # `/v2/Trunking/Numbers/{Destination}` β€” origin-aware single-number fetch.
92
+ class PricingV2TrunkingNumbersResource
93
+ def initialize(transport)
94
+ @transport = transport
95
+ end
96
+
97
+ def fetch(destination_number, origination_number: nil)
98
+ params = {}
99
+ params['OriginationNumber'] = origination_number unless origination_number.nil?
100
+ PricingTrunkingNumber.from_hash(
101
+ @transport.request(:get, "/v2/Trunking/Numbers/#{URI.encode_www_form_component(destination_number)}",
102
+ params: params)
103
+ )
104
+ end
105
+ end
106
+
107
+ # `client.pricing.v1.*` β€” Voice, Messaging, PhoneNumbers.
108
+ class PricingV1Resource
109
+ attr_reader :voice, :messaging, :phone_numbers
110
+
111
+ def initialize(transport)
112
+ @voice = PricingProduct.new(
113
+ PricingCountriesResource.new(transport, '/v1/Voice/Countries', PricingVoiceCountry),
114
+ PricingV1VoiceNumbersResource.new(transport)
115
+ )
116
+ @messaging = PricingProduct.new(
117
+ PricingCountriesResource.new(transport, '/v1/Messaging/Countries', PricingMessagingCountry)
118
+ )
119
+ @phone_numbers = PricingProduct.new(
120
+ PricingCountriesResource.new(transport, '/v1/PhoneNumbers/Countries', PricingPhoneNumberCountry)
121
+ )
122
+ end
123
+ end
124
+
125
+ # `client.pricing.v2.*` β€” Voice, Trunking.
126
+ class PricingV2Resource
127
+ attr_reader :voice, :trunking
128
+
129
+ def initialize(transport)
130
+ @voice = PricingProduct.new(
131
+ PricingCountriesResource.new(transport, '/v2/Voice/Countries', PricingVoiceCountryV2),
132
+ PricingV2VoiceNumbersResource.new(transport)
133
+ )
134
+ @trunking = PricingProduct.new(
135
+ PricingCountriesResource.new(transport, '/v2/Trunking/Countries', PricingTrunkingCountry),
136
+ PricingV2TrunkingNumbersResource.new(transport)
137
+ )
138
+ end
139
+ end
140
+ end
@@ -66,10 +66,14 @@ module VoiceML
66
66
  # Perform a request. Pass `form:` for a form-urlencoded POST body, `json:` for a JSON body,
67
67
  # or neither for a plain GET/DELETE. `params:` are query-string params.
68
68
  #
69
+ # `base_url:` overrides the client's default host for this one call (used to route product
70
+ # resources at their subdomain β€” Conversations, Messaging Service). When set, the request
71
+ # goes to an absolute URL so the correct host/SNI is used per request; see `ScopedTransport`.
72
+ #
69
73
  # @return [Hash, Array, nil] the parsed JSON body (or `nil` for empty 2xx).
70
74
  # @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)
75
+ def request(method, path, params: nil, form: nil, json: nil, base_url: nil)
76
+ uri = build_uri(request_path(path, base_url), params)
73
77
 
74
78
  attempt = 0
75
79
  loop do
@@ -95,8 +99,8 @@ module VoiceML
95
99
  # redirect that `GET /Recordings/{sid}.wav` issues when audio has been archived.
96
100
  #
97
101
  # @return [Array(Integer, String, Hash)] status code, response body bytes, header hash.
98
- def fetch_bytes(path)
99
- uri = build_uri(path, nil)
102
+ def fetch_bytes(path, base_url: nil)
103
+ uri = build_uri(request_path(path, base_url), nil)
100
104
  visited = []
101
105
  loop do
102
106
  raise ApiError.new('too many redirects', status_code: 0) if visited.length > 5
@@ -126,6 +130,14 @@ module VoiceML
126
130
  Errno::EHOSTUNREACH, SocketError, EOFError]
127
131
  end
128
132
 
133
+ # Prefix `path` with a per-request `base_url` override, if any. Leaves absolute paths and
134
+ # the default-host case untouched so `build_uri` handles them as before.
135
+ def request_path(path, base_url)
136
+ return path if base_url.nil? || base_url.empty?
137
+
138
+ "#{base_url}#{path}"
139
+ end
140
+
129
141
  def build_uri(path, params)
130
142
  uri = if path.start_with?('http://', 'https://')
131
143
  URI.parse(path)
@@ -293,4 +305,32 @@ module VoiceML
293
305
  [8.0, 0.5 * (2**attempt)].min
294
306
  end
295
307
  end
308
+
309
+ # A transport view pinned to a specific product base URL.
310
+ #
311
+ # Wraps a real `Transport` and forwards every call, injecting a `base_url` override so an
312
+ # entire resource group lands on its product subdomain (`conversations.voicetel.com` /
313
+ # `messaging.voicetel.com`) without each resource needing to know its own host.
314
+ #
315
+ # @api private
316
+ class ScopedTransport
317
+ attr_reader :base_url
318
+
319
+ def initialize(inner, base_url)
320
+ @inner = inner
321
+ @base_url = Transport.strip_trailing_slashes(base_url)
322
+ end
323
+
324
+ def account_sid
325
+ @inner.account_sid
326
+ end
327
+
328
+ def request(method, path, params: nil, form: nil, json: nil)
329
+ @inner.request(method, path, params: params, form: form, json: json, base_url: @base_url)
330
+ end
331
+
332
+ def fetch_bytes(path)
333
+ @inner.fetch_bytes(path, base_url: @base_url)
334
+ end
335
+ end
296
336
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module VoiceML
4
- VERSION = '0.9.1'
4
+ VERSION = '0.9.2'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: voiceml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - VoiceTel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-28 00:00:00.000000000 Z
11
+ date: 2026-07-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -64,6 +64,7 @@ files:
64
64
  - lib/voiceml.rb
65
65
  - lib/voiceml/client.rb
66
66
  - lib/voiceml/errors.rb
67
+ - lib/voiceml/hosts.rb
67
68
  - lib/voiceml/models/accounts.rb
68
69
  - lib/voiceml/models/applications.rb
69
70
  - lib/voiceml/models/assistants_v1.rb
@@ -75,9 +76,11 @@ files:
75
76
  - lib/voiceml/models/incoming_phone_numbers.rb
76
77
  - lib/voiceml/models/media.rb
77
78
  - lib/voiceml/models/messages.rb
79
+ - lib/voiceml/models/messaging_v1.rb
78
80
  - lib/voiceml/models/notifications.rb
79
81
  - lib/voiceml/models/outgoing_caller_ids.rb
80
82
  - lib/voiceml/models/payments.rb
83
+ - lib/voiceml/models/pricing.rb
81
84
  - lib/voiceml/models/queues.rb
82
85
  - lib/voiceml/models/recordings.rb
83
86
  - lib/voiceml/models/routes_v2.rb
@@ -96,7 +99,9 @@ files:
96
99
  - lib/voiceml/resources/diagnostics.rb
97
100
  - lib/voiceml/resources/incoming_phone_numbers.rb
98
101
  - lib/voiceml/resources/messages.rb
102
+ - lib/voiceml/resources/messaging_v1.rb
99
103
  - lib/voiceml/resources/notifications.rb
104
+ - lib/voiceml/resources/pricing.rb
100
105
  - lib/voiceml/resources/queues.rb
101
106
  - lib/voiceml/resources/recordings.rb
102
107
  - lib/voiceml/resources/routes_v2.rb