voiceml 0.9.2 → 0.9.3

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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -13
  3. data/lib/voiceml/client.rb +53 -13
  4. data/lib/voiceml/errors.rb +15 -2
  5. data/lib/voiceml/models/accounts.rb +47 -3
  6. data/lib/voiceml/models/applications.rb +6 -0
  7. data/lib/voiceml/models/assistants_v1.rb +40 -0
  8. data/lib/voiceml/models/calls.rb +4 -0
  9. data/lib/voiceml/models/common.rb +35 -4
  10. data/lib/voiceml/models/conferences.rb +8 -0
  11. data/lib/voiceml/models/conversations_v1.rb +131 -29
  12. data/lib/voiceml/models/diagnostics.rb +33 -12
  13. data/lib/voiceml/models/exports.rb +58 -0
  14. data/lib/voiceml/models/incoming_phone_numbers.rb +4 -0
  15. data/lib/voiceml/models/media.rb +4 -0
  16. data/lib/voiceml/models/messages.rb +29 -0
  17. data/lib/voiceml/models/messaging_v1.rb +35 -2
  18. data/lib/voiceml/models/monitor_v1.rb +54 -0
  19. data/lib/voiceml/models/notifications.rb +2 -0
  20. data/lib/voiceml/models/outgoing_caller_ids.rb +6 -0
  21. data/lib/voiceml/models/payments.rb +2 -0
  22. data/lib/voiceml/models/pricing.rb +38 -4
  23. data/lib/voiceml/models/queues.rb +8 -0
  24. data/lib/voiceml/models/recordings.rb +5 -1
  25. data/lib/voiceml/models/routes_v2.rb +6 -2
  26. data/lib/voiceml/models/sip.rb +34 -6
  27. data/lib/voiceml/models/sip_registrations.rb +49 -0
  28. data/lib/voiceml/models/siprec.rb +5 -17
  29. data/lib/voiceml/models/streams.rb +4 -16
  30. data/lib/voiceml/models/transcriptions.rb +35 -4
  31. data/lib/voiceml/models/usage_records.rb +48 -0
  32. data/lib/voiceml/models/user_defined_messages.rb +2 -0
  33. data/lib/voiceml/models/voice_v1.rb +29 -19
  34. data/lib/voiceml/request_validator.rb +125 -0
  35. data/lib/voiceml/resources/accounts.rb +75 -0
  36. data/lib/voiceml/resources/applications.rb +3 -1
  37. data/lib/voiceml/resources/assistants_v1.rb +53 -77
  38. data/lib/voiceml/resources/base.rb +138 -16
  39. data/lib/voiceml/resources/calls.rb +34 -49
  40. data/lib/voiceml/resources/conferences.rb +26 -5
  41. data/lib/voiceml/resources/conversations_v1.rb +147 -172
  42. data/lib/voiceml/resources/diagnostics.rb +20 -35
  43. data/lib/voiceml/resources/exports.rb +36 -0
  44. data/lib/voiceml/resources/incoming_phone_numbers.rb +53 -32
  45. data/lib/voiceml/resources/messages.rb +79 -15
  46. data/lib/voiceml/resources/messaging_v1.rb +47 -13
  47. data/lib/voiceml/resources/monitor_v1.rb +68 -0
  48. data/lib/voiceml/resources/notifications.rb +1 -1
  49. data/lib/voiceml/resources/outgoing_caller_ids.rb +77 -0
  50. data/lib/voiceml/resources/pricing.rb +13 -5
  51. data/lib/voiceml/resources/queues.rb +6 -5
  52. data/lib/voiceml/resources/recordings.rb +39 -5
  53. data/lib/voiceml/resources/routes_v2.rb +10 -4
  54. data/lib/voiceml/resources/sip.rb +30 -11
  55. data/lib/voiceml/resources/transcriptions.rb +40 -0
  56. data/lib/voiceml/resources/usage_records.rb +51 -0
  57. data/lib/voiceml/resources/voice_v1.rb +31 -38
  58. data/lib/voiceml/transport.rb +277 -230
  59. data/lib/voiceml/version.rb +1 -1
  60. data/lib/voiceml.rb +14 -15
  61. metadata +18 -6
@@ -10,7 +10,7 @@ module VoiceML
10
10
  # - List pages carry a `meta` envelope (URLs + page/page_size + key) instead of
11
11
  # the flat Twilio2010 `next_page_uri` shape.
12
12
  #
13
- # Six resources implemented for v0.9.0:
13
+ # Six resources implemented:
14
14
  # - ByocTrunk (BY...) — bring-your-own-carrier trunk
15
15
  # - ConnectionPolicy (NY...) — origination policy
16
16
  # - ConnectionPolicyTarget (NE...)
@@ -18,35 +18,27 @@ module VoiceML
18
18
  # - SourceIpMapping (IB...) — bind IpRecord -> SipDomain
19
19
  # - IpRecord (IL...) — standalone allowed source IP
20
20
 
21
- # Shared `meta` envelope for `/v1/*` list responses.
22
- module V1Pageable
23
- META_FIELDS = %w[first_page_url next_page_url previous_page_url url page page_size key].freeze
24
-
25
- META_FIELDS.each { |f| attr_reader f.to_sym }
26
-
27
- def assign_meta_fields(hash)
28
- meta = hash['meta'] || {}
29
- META_FIELDS.each { |f| instance_variable_set("@#{f}", meta[f]) }
30
- end
31
- end
32
-
33
21
  # VoiceV1IpRecord — `IL...`.
34
22
  class VoiceV1IpRecord
23
+ include RawAttributes
35
24
  ATTRIBUTES = %w[
36
25
  account_sid sid friendly_name ip_address cidr_prefix_length
37
26
  date_created date_updated url
38
27
  ].freeze
39
28
  attr_reader(*ATTRIBUTES.map(&:to_sym))
40
29
  def initialize(attrs = {})
41
- ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs[f] || attrs[f.to_sym]) }
30
+ @raw_attributes = attrs
31
+ ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs.key?(f) ? attrs[f] : attrs[f.to_sym]) }
42
32
  end
43
33
  def self.from_hash(h); h.nil? ? nil : new(h); end
44
34
  end
45
35
 
46
36
  class VoiceV1IpRecordList
37
+ include RawAttributes
47
38
  include V1Pageable
48
39
  attr_reader :ip_records
49
40
  def initialize(hash = {})
41
+ @raw_attributes = hash
50
42
  assign_meta_fields(hash)
51
43
  @ip_records = (hash['ip_records'] || []).map { |h| VoiceV1IpRecord.from_hash(h) }
52
44
  end
@@ -54,20 +46,24 @@ module VoiceML
54
46
 
55
47
  # VoiceV1SourceIpMapping — `IB...`. Binds an IpRecord to a SipDomain.
56
48
  class VoiceV1SourceIpMapping
49
+ include RawAttributes
57
50
  ATTRIBUTES = %w[
58
51
  sid ip_record_sid sip_domain_sid date_created date_updated url
59
52
  ].freeze
60
53
  attr_reader(*ATTRIBUTES.map(&:to_sym))
61
54
  def initialize(attrs = {})
62
- ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs[f] || attrs[f.to_sym]) }
55
+ @raw_attributes = attrs
56
+ ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs.key?(f) ? attrs[f] : attrs[f.to_sym]) }
63
57
  end
64
58
  def self.from_hash(h); h.nil? ? nil : new(h); end
65
59
  end
66
60
 
67
61
  class VoiceV1SourceIpMappingList
62
+ include RawAttributes
68
63
  include V1Pageable
69
64
  attr_reader :source_ip_mappings
70
65
  def initialize(hash = {})
66
+ @raw_attributes = hash
71
67
  assign_meta_fields(hash)
72
68
  @source_ip_mappings = (hash['source_ip_mappings'] || []).map { |h| VoiceV1SourceIpMapping.from_hash(h) }
73
69
  end
@@ -75,6 +71,7 @@ module VoiceML
75
71
 
76
72
  # VoiceV1ByocTrunk — `BY...`. Bring-your-own-carrier trunk.
77
73
  class VoiceV1ByocTrunk
74
+ include RawAttributes
78
75
  ATTRIBUTES = %w[
79
76
  account_sid sid friendly_name voice_url voice_method voice_fallback_url
80
77
  voice_fallback_method status_callback_url status_callback_method
@@ -83,15 +80,18 @@ module VoiceML
83
80
  ].freeze
84
81
  attr_reader(*ATTRIBUTES.map(&:to_sym))
85
82
  def initialize(attrs = {})
86
- ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs[f] || attrs[f.to_sym]) }
83
+ @raw_attributes = attrs
84
+ ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs.key?(f) ? attrs[f] : attrs[f.to_sym]) }
87
85
  end
88
86
  def self.from_hash(h); h.nil? ? nil : new(h); end
89
87
  end
90
88
 
91
89
  class VoiceV1ByocTrunkList
90
+ include RawAttributes
92
91
  include V1Pageable
93
92
  attr_reader :byoc_trunks
94
93
  def initialize(hash = {})
94
+ @raw_attributes = hash
95
95
  assign_meta_fields(hash)
96
96
  @byoc_trunks = (hash['byoc_trunks'] || []).map { |h| VoiceV1ByocTrunk.from_hash(h) }
97
97
  end
@@ -99,20 +99,24 @@ module VoiceML
99
99
 
100
100
  # VoiceV1ConnectionPolicy — `NY...`.
101
101
  class VoiceV1ConnectionPolicy
102
+ include RawAttributes
102
103
  ATTRIBUTES = %w[
103
104
  account_sid sid friendly_name date_created date_updated url links
104
105
  ].freeze
105
106
  attr_reader(*ATTRIBUTES.map(&:to_sym))
106
107
  def initialize(attrs = {})
107
- ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs[f] || attrs[f.to_sym]) }
108
+ @raw_attributes = attrs
109
+ ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs.key?(f) ? attrs[f] : attrs[f.to_sym]) }
108
110
  end
109
111
  def self.from_hash(h); h.nil? ? nil : new(h); end
110
112
  end
111
113
 
112
114
  class VoiceV1ConnectionPolicyList
115
+ include RawAttributes
113
116
  include V1Pageable
114
117
  attr_reader :connection_policies
115
118
  def initialize(hash = {})
119
+ @raw_attributes = hash
116
120
  assign_meta_fields(hash)
117
121
  @connection_policies = (hash['connection_policies'] || []).map { |h| VoiceV1ConnectionPolicy.from_hash(h) }
118
122
  end
@@ -120,21 +124,25 @@ module VoiceML
120
124
 
121
125
  # VoiceV1ConnectionPolicyTarget — `NE...`. Scoped to a parent ConnectionPolicy.
122
126
  class VoiceV1ConnectionPolicyTarget
127
+ include RawAttributes
123
128
  ATTRIBUTES = %w[
124
129
  account_sid connection_policy_sid sid friendly_name target priority weight
125
130
  enabled date_created date_updated url
126
131
  ].freeze
127
132
  attr_reader(*ATTRIBUTES.map(&:to_sym))
128
133
  def initialize(attrs = {})
129
- ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs[f] || attrs[f.to_sym]) }
134
+ @raw_attributes = attrs
135
+ ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs.key?(f) ? attrs[f] : attrs[f.to_sym]) }
130
136
  end
131
137
  def self.from_hash(h); h.nil? ? nil : new(h); end
132
138
  end
133
139
 
134
140
  class VoiceV1ConnectionPolicyTargetList
141
+ include RawAttributes
135
142
  include V1Pageable
136
143
  attr_reader :targets
137
144
  def initialize(hash = {})
145
+ @raw_attributes = hash
138
146
  assign_meta_fields(hash)
139
147
  @targets = (hash['targets'] || []).map { |h| VoiceV1ConnectionPolicyTarget.from_hash(h) }
140
148
  end
@@ -142,10 +150,12 @@ module VoiceML
142
150
 
143
151
  # VoiceV1DialingPermissionsSettings — `/v1/Settings`.
144
152
  class VoiceV1DialingPermissionsSettings
153
+ include RawAttributes
145
154
  ATTRIBUTES = %w[dialing_permissions_inheritance url].freeze
146
155
  attr_reader(*ATTRIBUTES.map(&:to_sym))
147
156
  def initialize(attrs = {})
148
- ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs[f] || attrs[f.to_sym]) }
157
+ @raw_attributes = attrs
158
+ ATTRIBUTES.each { |f| instance_variable_set("@#{f}", attrs.key?(f) ? attrs[f] : attrs[f.to_sym]) }
149
159
  end
150
160
  def self.from_hash(h); h.nil? ? nil : new(h); end
151
161
  end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module VoiceML
6
+ # Validates the `X-Twilio-Signature` header on inbound VoiceML webhooks, and
7
+ # computes signatures for testing server-side handlers.
8
+ #
9
+ # The algorithm (matching the VoiceML server):
10
+ #
11
+ # 1. Take the full request URL with any `#fragment` stripped.
12
+ # 2. For a POST, append `key + value` for every form pair, sorted ascending
13
+ # by key in byte order; repeated keys keep their received value order.
14
+ # For a GET, the URL alone is signed.
15
+ # 3. HMAC-SHA1 with the account API key as the key, then standard Base64
16
+ # (padded).
17
+ #
18
+ # Comparison is constant-time. Like Twilio's validators, a URL that differs
19
+ # only by an explicit default port (`:443` for https, `:80` for http) also
20
+ # validates, in both directions.
21
+ #
22
+ # @example
23
+ # validator = VoiceML::RequestValidator.new(api_key)
24
+ # valid = validator.valid?(request.url, request.params, request.headers['X-Twilio-Signature'])
25
+ class RequestValidator
26
+ # @param api_key [String] the account API key the server signs with.
27
+ def initialize(api_key)
28
+ @credential = -> { api_key }
29
+ end
30
+
31
+ # Compute the signature for a URL and params. `params` may be a Hash (use
32
+ # an Array value for repeated keys) or an Array of `[key, value]` pairs in
33
+ # their received order.
34
+ #
35
+ # @return [String] Base64 HMAC-SHA1 digest.
36
+ def signature(url, params = {}, method: 'POST')
37
+ data = signature_base(url, method.to_s.upcase == 'POST' ? params : nil)
38
+ digest = OpenSSL::HMAC.digest('SHA1', @credential.call, data)
39
+ [digest].pack('m0')
40
+ end
41
+
42
+ # Validate an incoming webhook. `params` may be a Hash (Array value for
43
+ # repeated keys) or an Array of `[key, value]` pairs in received order.
44
+ #
45
+ # @return [Boolean]
46
+ def valid?(url, params, signature, method: 'POST')
47
+ given = signature.to_s
48
+ candidates = [signature(url, params, method: method)]
49
+ variant = port_variant(url)
50
+ candidates << signature(variant, params, method: method) unless variant.nil?
51
+
52
+ candidates.map { |expected| constant_time_equal?(expected, given) }.any?
53
+ end
54
+ alias validate valid?
55
+
56
+ private
57
+
58
+ # The string that gets HMAC'd: URL (fragment stripped) plus, for POSTs,
59
+ # `key + value` per pair sorted by key byte order, repeated keys keeping
60
+ # their received value order.
61
+ def signature_base(url, params)
62
+ base = url.to_s.sub(/#.*\z/, '')
63
+ return base if params.nil?
64
+
65
+ pairs = pairs_from(params)
66
+ return base if pairs.empty?
67
+
68
+ sorted = pairs.each_with_index.to_a
69
+ .sort_by { |(pair, index)| [pair.first.b, index] }
70
+ .map(&:first)
71
+ base + sorted.map { |key, value| "#{key}#{value}" }.join
72
+ end
73
+
74
+ def pairs_from(params)
75
+ case params
76
+ when Array then params.map { |key, value| [key.to_s, value.to_s] }
77
+ else
78
+ pairs = []
79
+ params.each do |key, value|
80
+ if value.is_a?(Array)
81
+ value.each { |entry| pairs << [key.to_s, entry.to_s] }
82
+ else
83
+ pairs << [key.to_s, value.to_s]
84
+ end
85
+ end
86
+ pairs
87
+ end
88
+ end
89
+
90
+ # The same URL with the default port added or removed (`:443` for https,
91
+ # `:80` for http), or nil when the URL has no such variant. String-based:
92
+ # URI round-trips normalise an explicit default port away.
93
+ def port_variant(url)
94
+ s = url.to_s
95
+ match = s.match(%r{\Ahttps?://[^/?#]*})
96
+ return nil if match.nil?
97
+
98
+ prefix = match[0]
99
+ rest = s[prefix.length..] || ''
100
+ if prefix =~ %r{\Ahttps://[^/?#]+:443\z}
101
+ prefix.sub(/:443\z/, '') + rest
102
+ elsif prefix =~ %r{\Ahttps://[^/?#:]+\z}
103
+ "#{prefix}:443#{rest}"
104
+ elsif prefix =~ %r{\Ahttp://[^/?#]+:80\z}
105
+ prefix.sub(/:80\z/, '') + rest
106
+ elsif prefix =~ %r{\Ahttp://[^/?#:]+\z}
107
+ "#{prefix}:80#{rest}"
108
+ end
109
+ end
110
+
111
+ def constant_time_equal?(a, b)
112
+ return false unless a.bytesize == b.bytesize
113
+
114
+ OpenSSL.fixed_length_secure_compare(a, b)
115
+ end
116
+ end
117
+
118
+ # Convenience wrapper: validate one webhook without instantiating a
119
+ # {RequestValidator}.
120
+ #
121
+ # @return [Boolean]
122
+ def self.validate_request(api_key, url, params, signature, method: 'POST')
123
+ RequestValidator.new(api_key).valid?(url, params, signature, method: method)
124
+ end
125
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../models/accounts'
5
+
6
+ module VoiceML
7
+ # Operations on `/2010-04-01/Accounts/{Sid}` — the account record itself and
8
+ # its balance. `client.accounts` reads the authenticated account; `fetch`/
9
+ # `update` accept any AccountSid the credentials can see (in practice the
10
+ # credential's own account).
11
+ class AccountsResource < BaseResource
12
+ UPDATE_FIELDS = {
13
+ 'FriendlyName' => :friendly_name,
14
+ 'VoiceUrl' => :voice_url,
15
+ 'VoiceMethod' => :voice_method,
16
+ 'VoiceFallbackUrl' => :voice_fallback_url,
17
+ 'VoiceFallbackMethod' => :voice_fallback_method,
18
+ 'SttFsVendor' => :stt_fs_vendor,
19
+ 'SttHttpUrl' => :stt_http_url,
20
+ 'SttHttpToken' => :stt_http_token,
21
+ 'TtsHttpUrl' => :tts_http_url,
22
+ 'TtsHttpToken' => :tts_http_token,
23
+ 'TtsEngineName' => :tts_engine_name,
24
+ 'TtsDefaultVoice' => :tts_default_voice,
25
+ 'TtsDefaultLanguage' => :tts_default_language,
26
+ 'AwsBucket' => :aws_bucket,
27
+ 'AwsRegion' => :aws_region,
28
+ 'AwsPrefix' => :aws_prefix,
29
+ 'AwsAccessKeyId' => :aws_access_key_id,
30
+ 'AwsSecretAccessKey' => :aws_secret_access_key,
31
+ 'AwsKmsKeyId' => :aws_kms_key_id,
32
+ 'AllowCidr' => :allow_cidr
33
+ }.freeze
34
+
35
+ # Fetch the account record. Omit `sid` to read the authenticated account.
36
+ #
37
+ # @return [VoiceML::Account]
38
+ def fetch(sid = @transport.account_sid)
39
+ Account.from_hash(
40
+ @transport.request(:get, "/2010-04-01/Accounts/#{encode_path_segment(sid)}.json")
41
+ )
42
+ end
43
+
44
+ # Update the account record. A concurrent modification surfaces as
45
+ # `VoiceML::ConflictError` (HTTP 409 / code 20409 — optimistic locking).
46
+ #
47
+ # @return [VoiceML::Account]
48
+ def update(sid = @transport.account_sid, **kwargs)
49
+ Account.from_hash(
50
+ @transport.request(:post, "/2010-04-01/Accounts/#{encode_path_segment(sid)}.json",
51
+ form: form_params(UPDATE_FIELDS, kwargs))
52
+ )
53
+ end
54
+
55
+ # Fetch the account balance.
56
+ #
57
+ # @return [VoiceML::Balance]
58
+ def fetch_balance
59
+ Balance.from_hash(@transport.request(:get, path('Balance')))
60
+ end
61
+
62
+ # Rotate the account's API key (`POST /Accounts/{Sid}/RotateKey.json`,
63
+ # no request body). The previous key stops working immediately. The new
64
+ # key rides the returned {VoiceML::AccountWithKey} as `api_key` — no
65
+ # read endpoint exposes it, so store it right away. It never appears in
66
+ # the model's `inspect` / `to_s`.
67
+ #
68
+ # @return [VoiceML::AccountWithKey]
69
+ def rotate_key(sid = @transport.account_sid)
70
+ AccountWithKey.from_hash(
71
+ @transport.request(:post, "/2010-04-01/Accounts/#{encode_path_segment(sid)}/RotateKey.json")
72
+ )
73
+ end
74
+ end
75
+ end
@@ -15,7 +15,9 @@ module VoiceML
15
15
  'VoiceCallerIdLookup' => :voice_caller_id_lookup,
16
16
  'StatusCallback' => :status_callback,
17
17
  'StatusCallbackMethod' => :status_callback_method,
18
- 'StatusCallbackEvent' => :status_callback_event
18
+ 'StatusCallbackEvent' => :status_callback_event,
19
+ 'MessageStatusCallback' => :message_status_callback,
20
+ 'SmsStatusCallback' => :sms_status_callback
19
21
  }.freeze
20
22
 
21
23
  LIST_FIELDS = {