signalwire-sdk 2.0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +259 -0
- data/bin/swaig-test +872 -0
- data/lib/signalwire/agent/agent_base.rb +2134 -0
- data/lib/signalwire/contexts/context_builder.rb +861 -0
- data/lib/signalwire/core/logging_config.rb +54 -0
- data/lib/signalwire/datamap/data_map.rb +315 -0
- data/lib/signalwire/logging.rb +92 -0
- data/lib/signalwire/pom/prompt_object_model.rb +269 -0
- data/lib/signalwire/pom/section.rb +202 -0
- data/lib/signalwire/prefabs/concierge.rb +92 -0
- data/lib/signalwire/prefabs/faq_bot.rb +67 -0
- data/lib/signalwire/prefabs/info_gatherer.rb +79 -0
- data/lib/signalwire/prefabs/receptionist.rb +74 -0
- data/lib/signalwire/prefabs/survey.rb +75 -0
- data/lib/signalwire/relay/action.rb +291 -0
- data/lib/signalwire/relay/call.rb +523 -0
- data/lib/signalwire/relay/client.rb +789 -0
- data/lib/signalwire/relay/constants.rb +124 -0
- data/lib/signalwire/relay/message.rb +137 -0
- data/lib/signalwire/relay/relay_event.rb +670 -0
- data/lib/signalwire/rest/http_client.rb +159 -0
- data/lib/signalwire/rest/namespaces/addresses.rb +19 -0
- data/lib/signalwire/rest/namespaces/calling.rb +179 -0
- data/lib/signalwire/rest/namespaces/chat.rb +18 -0
- data/lib/signalwire/rest/namespaces/compat.rb +229 -0
- data/lib/signalwire/rest/namespaces/datasphere.rb +39 -0
- data/lib/signalwire/rest/namespaces/fabric.rb +235 -0
- data/lib/signalwire/rest/namespaces/imported_numbers.rb +18 -0
- data/lib/signalwire/rest/namespaces/logs.rb +46 -0
- data/lib/signalwire/rest/namespaces/lookup.rb +18 -0
- data/lib/signalwire/rest/namespaces/mfa.rb +26 -0
- data/lib/signalwire/rest/namespaces/number_groups.rb +32 -0
- data/lib/signalwire/rest/namespaces/phone_numbers.rb +124 -0
- data/lib/signalwire/rest/namespaces/project.rb +33 -0
- data/lib/signalwire/rest/namespaces/pubsub.rb +18 -0
- data/lib/signalwire/rest/namespaces/queues.rb +28 -0
- data/lib/signalwire/rest/namespaces/recordings.rb +18 -0
- data/lib/signalwire/rest/namespaces/registry.rb +67 -0
- data/lib/signalwire/rest/namespaces/short_codes.rb +26 -0
- data/lib/signalwire/rest/namespaces/sip_profile.rb +22 -0
- data/lib/signalwire/rest/namespaces/verified_callers.rb +24 -0
- data/lib/signalwire/rest/namespaces/video.rb +129 -0
- data/lib/signalwire/rest/pagination.rb +89 -0
- data/lib/signalwire/rest/phone_call_handler.rb +56 -0
- data/lib/signalwire/rest/rest_client.rb +114 -0
- data/lib/signalwire/runtime.rb +98 -0
- data/lib/signalwire/security/session_manager.rb +124 -0
- data/lib/signalwire/security/webhook_middleware.rb +191 -0
- data/lib/signalwire/security/webhook_validator.rb +327 -0
- data/lib/signalwire/server/agent_server.rb +413 -0
- data/lib/signalwire/serverless/lambda_handler.rb +251 -0
- data/lib/signalwire/skills/builtin/api_ninjas_trivia.rb +99 -0
- data/lib/signalwire/skills/builtin/claude_skills.rb +92 -0
- data/lib/signalwire/skills/builtin/custom_skills.rb +54 -0
- data/lib/signalwire/skills/builtin/datasphere.rb +153 -0
- data/lib/signalwire/skills/builtin/datasphere_serverless.rb +107 -0
- data/lib/signalwire/skills/builtin/datetime.rb +97 -0
- data/lib/signalwire/skills/builtin/google_maps.rb +168 -0
- data/lib/signalwire/skills/builtin/info_gatherer.rb +189 -0
- data/lib/signalwire/skills/builtin/joke.rb +65 -0
- data/lib/signalwire/skills/builtin/math.rb +176 -0
- data/lib/signalwire/skills/builtin/mcp_gateway.rb +121 -0
- data/lib/signalwire/skills/builtin/native_vector_search.rb +116 -0
- data/lib/signalwire/skills/builtin/play_background_file.rb +86 -0
- data/lib/signalwire/skills/builtin/spider.rb +169 -0
- data/lib/signalwire/skills/builtin/swml_transfer.rb +118 -0
- data/lib/signalwire/skills/builtin/weather_api.rb +92 -0
- data/lib/signalwire/skills/builtin/web_search.rb +141 -0
- data/lib/signalwire/skills/builtin/wikipedia_search.rb +125 -0
- data/lib/signalwire/skills/skill_base.rb +82 -0
- data/lib/signalwire/skills/skill_manager.rb +97 -0
- data/lib/signalwire/skills/skill_registry.rb +258 -0
- data/lib/signalwire/swaig/function_result.rb +777 -0
- data/lib/signalwire/swml/document.rb +84 -0
- data/lib/signalwire/swml/schema.json +12250 -0
- data/lib/signalwire/swml/schema.rb +81 -0
- data/lib/signalwire/swml/service.rb +650 -0
- data/lib/signalwire/utils/schema_utils.rb +298 -0
- data/lib/signalwire/utils/serverless.rb +19 -0
- data/lib/signalwire/utils/url_validator.rb +138 -0
- data/lib/signalwire/version.rb +5 -0
- data/lib/signalwire.rb +114 -0
- metadata +225 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2025 SignalWire
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the MIT License.
|
|
6
|
+
# See LICENSE file in the project root for full license information.
|
|
7
|
+
|
|
8
|
+
require 'json'
|
|
9
|
+
|
|
10
|
+
module SignalWire
|
|
11
|
+
module Utils
|
|
12
|
+
# SchemaValidationError — Ruby port of
|
|
13
|
+
# signalwire.utils.schema_utils.SchemaValidationError.
|
|
14
|
+
#
|
|
15
|
+
# Raised when SWML schema validation of a verb config fails.
|
|
16
|
+
class SchemaValidationError < StandardError
|
|
17
|
+
attr_reader :verb_name, :errors
|
|
18
|
+
|
|
19
|
+
# Construct a SchemaValidationError. Mirrors Python's
|
|
20
|
+
# SchemaValidationError(verb_name, errors).
|
|
21
|
+
def initialize(verb_name, errors)
|
|
22
|
+
@verb_name = verb_name
|
|
23
|
+
@errors = errors || []
|
|
24
|
+
message = "Schema validation failed for '#{verb_name}': #{@errors.join('; ')}"
|
|
25
|
+
super(message)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# SchemaUtils — Ruby port of signalwire.utils.schema_utils.SchemaUtils.
|
|
30
|
+
#
|
|
31
|
+
# Loads the SWML JSON Schema, extracts verb metadata, and validates
|
|
32
|
+
# either a single verb config or a complete SWML document.
|
|
33
|
+
#
|
|
34
|
+
# Construction rules mirror Python:
|
|
35
|
+
# - Pass schema_path: nil to use the bundled schema.json.
|
|
36
|
+
# - schema_validation: false disables validation (validate_verb returns
|
|
37
|
+
# true for every call).
|
|
38
|
+
# - The env var SWML_SKIP_SCHEMA_VALIDATION=1/true/yes also disables
|
|
39
|
+
# validation regardless of the constructor argument.
|
|
40
|
+
#
|
|
41
|
+
# The Ruby port currently ships only the lightweight validator (verb
|
|
42
|
+
# existence + required-property check). Full JSON Schema validation
|
|
43
|
+
# can be wired in via the `json_schemer` gem by extending
|
|
44
|
+
# init_full_validator. The lightweight contract matches Python's
|
|
45
|
+
# _validate_verb_lightweight() exactly.
|
|
46
|
+
class SchemaUtils
|
|
47
|
+
# @return [Hash{String=>Object}] parsed JSON Schema document
|
|
48
|
+
attr_reader :schema
|
|
49
|
+
|
|
50
|
+
# @return [String, nil] resolved schema path (nil = embedded default)
|
|
51
|
+
attr_reader :schema_path
|
|
52
|
+
|
|
53
|
+
# Construct a SchemaUtils.
|
|
54
|
+
#
|
|
55
|
+
# @param schema_path [String, nil] path to a schema.json file; nil for
|
|
56
|
+
# the bundled copy at lib/signalwire/swml/schema.json.
|
|
57
|
+
# @param schema_validation [Boolean] enable/disable schema validation.
|
|
58
|
+
def initialize(schema_path = nil, schema_validation = true)
|
|
59
|
+
env_skip = env_boolish(ENV.fetch('SWML_SKIP_SCHEMA_VALIDATION', ''))
|
|
60
|
+
@schema_path = schema_path
|
|
61
|
+
@validation_enabled = schema_validation && !env_skip
|
|
62
|
+
@schema = load_schema
|
|
63
|
+
@verbs = {}
|
|
64
|
+
extract_verbs
|
|
65
|
+
init_full_validator if @validation_enabled && !@schema.empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether full JSON Schema validation is wired up.
|
|
69
|
+
# Mirrors Python's full_validation_available property.
|
|
70
|
+
def full_validation_available?
|
|
71
|
+
!@full_validator.nil?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Read and parse the JSON Schema. Mirrors Python's load_schema().
|
|
75
|
+
def load_schema
|
|
76
|
+
path = @schema_path || default_schema_path
|
|
77
|
+
return {} if path.nil? || !File.exist?(path)
|
|
78
|
+
|
|
79
|
+
raw = File.read(path, encoding: 'UTF-8')
|
|
80
|
+
JSON.parse(raw)
|
|
81
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
82
|
+
{}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Sorted list of all known verb names.
|
|
86
|
+
# Mirrors Python's get_all_verb_names().
|
|
87
|
+
def get_all_verb_names
|
|
88
|
+
@verbs.keys.sort
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The properties[verb_name] block for a verb, or {} when unknown.
|
|
92
|
+
# Mirrors Python's get_verb_properties(verb_name).
|
|
93
|
+
def get_verb_properties(verb_name)
|
|
94
|
+
v = @verbs[verb_name]
|
|
95
|
+
return {} if v.nil?
|
|
96
|
+
|
|
97
|
+
outer_props = v['definition']['properties'] rescue nil
|
|
98
|
+
return {} unless outer_props.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
inner = outer_props[verb_name]
|
|
101
|
+
return {} unless inner.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
inner
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The required list for a verb, or [] when unknown / no required.
|
|
107
|
+
# Mirrors Python's get_verb_required_properties(verb_name).
|
|
108
|
+
def get_verb_required_properties(verb_name)
|
|
109
|
+
inner = get_verb_properties(verb_name)
|
|
110
|
+
req = inner['required']
|
|
111
|
+
return [] unless req.is_a?(Array)
|
|
112
|
+
|
|
113
|
+
req.select { |x| x.is_a?(String) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Parameter-definition block used by code-gen tooling.
|
|
117
|
+
# Mirrors Python's get_verb_parameters(verb_name).
|
|
118
|
+
def get_verb_parameters(verb_name)
|
|
119
|
+
inner = get_verb_properties(verb_name)
|
|
120
|
+
props = inner['properties']
|
|
121
|
+
return {} unless props.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
props
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Validate a verb config against the schema.
|
|
127
|
+
# Mirrors Python's validate_verb(verb_name, verb_config).
|
|
128
|
+
#
|
|
129
|
+
# @return [Array(Boolean, Array<String>)] (valid, errors) tuple.
|
|
130
|
+
def validate_verb(verb_name, verb_config)
|
|
131
|
+
return [true, []] unless @validation_enabled
|
|
132
|
+
|
|
133
|
+
unless @verbs.key?(verb_name)
|
|
134
|
+
return [false, ["Unknown verb: #{verb_name}"]]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if @full_validator
|
|
138
|
+
validate_verb_full(verb_name, verb_config)
|
|
139
|
+
else
|
|
140
|
+
validate_verb_lightweight(verb_name, verb_config)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Validate a complete SWML document.
|
|
145
|
+
# Mirrors Python's validate_document(document). Returns
|
|
146
|
+
# (false, ['Schema validator not initialized']) when no full
|
|
147
|
+
# validator is wired in.
|
|
148
|
+
def validate_document(document)
|
|
149
|
+
if @full_validator.nil?
|
|
150
|
+
return [false, ['Schema validator not initialized']]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Reserved for full-validator wiring.
|
|
154
|
+
[true, []]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Generate a Python-style method signature string for a verb.
|
|
158
|
+
# Mirrors Python's generate_method_signature(verb_name).
|
|
159
|
+
def generate_method_signature(verb_name)
|
|
160
|
+
params = get_verb_parameters(verb_name)
|
|
161
|
+
required = get_verb_required_properties(verb_name).to_set
|
|
162
|
+
parts = ['self']
|
|
163
|
+
keys = params.keys.sort
|
|
164
|
+
keys.each do |name|
|
|
165
|
+
t = python_type_annotation(params[name])
|
|
166
|
+
parts << if required.include?(name)
|
|
167
|
+
"#{name}: #{t}"
|
|
168
|
+
else
|
|
169
|
+
"#{name}: Optional[#{t}] = None"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
parts << '**kwargs'
|
|
173
|
+
doc = +"\"\"\"\n Add the #{verb_name} verb to the current document\n \n"
|
|
174
|
+
keys.each do |name|
|
|
175
|
+
desc = ''
|
|
176
|
+
d = params[name]
|
|
177
|
+
if d.is_a?(Hash) && d['description']
|
|
178
|
+
desc = d['description'].to_s.gsub("\n", ' ').strip
|
|
179
|
+
end
|
|
180
|
+
doc << " Args:\n #{name}: #{desc}\n"
|
|
181
|
+
end
|
|
182
|
+
doc << " \n Returns:\n True if the verb was added successfully, False otherwise\n \"\"\"\n"
|
|
183
|
+
"def #{verb_name}(#{parts.join(', ')}) -> bool:\n#{doc}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Generate a Python-style method body string for a verb.
|
|
187
|
+
# Mirrors Python's generate_method_body(verb_name).
|
|
188
|
+
def generate_method_body(verb_name)
|
|
189
|
+
params = get_verb_parameters(verb_name)
|
|
190
|
+
keys = params.keys.sort
|
|
191
|
+
lines = [
|
|
192
|
+
' # Prepare the configuration',
|
|
193
|
+
' config = {}'
|
|
194
|
+
]
|
|
195
|
+
keys.each do |name|
|
|
196
|
+
lines << " if #{name} is not None:"
|
|
197
|
+
lines << " config['#{name}'] = #{name}"
|
|
198
|
+
end
|
|
199
|
+
lines << ' # Add any additional parameters from kwargs'
|
|
200
|
+
lines << ' for key, value in kwargs.items():'
|
|
201
|
+
lines << ' if value is not None:'
|
|
202
|
+
lines << ' config[key] = value'
|
|
203
|
+
lines << ''
|
|
204
|
+
lines << " # Add the #{verb_name} verb"
|
|
205
|
+
lines << " return self.add_verb('#{verb_name}', config)"
|
|
206
|
+
lines.join("\n")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private
|
|
210
|
+
|
|
211
|
+
def default_schema_path
|
|
212
|
+
# Bundled schema lives in lib/signalwire/swml/schema.json
|
|
213
|
+
File.expand_path('../swml/schema.json', __dir__)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def env_boolish(value)
|
|
217
|
+
%w[1 true yes].include?(value.to_s.strip.downcase)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def extract_verbs
|
|
221
|
+
defs = @schema['$defs']
|
|
222
|
+
return unless defs.is_a?(Hash)
|
|
223
|
+
|
|
224
|
+
swml_method = defs['SWMLMethod']
|
|
225
|
+
return unless swml_method.is_a?(Hash)
|
|
226
|
+
|
|
227
|
+
any_of = swml_method['anyOf']
|
|
228
|
+
return unless any_of.is_a?(Array)
|
|
229
|
+
|
|
230
|
+
any_of.each do |entry|
|
|
231
|
+
next unless entry.is_a?(Hash)
|
|
232
|
+
|
|
233
|
+
ref = entry['$ref']
|
|
234
|
+
next unless ref.is_a?(String)
|
|
235
|
+
|
|
236
|
+
prefix = '#/$defs/'
|
|
237
|
+
next unless ref.start_with?(prefix)
|
|
238
|
+
|
|
239
|
+
schema_name = ref[prefix.length..]
|
|
240
|
+
defn = defs[schema_name]
|
|
241
|
+
next unless defn.is_a?(Hash)
|
|
242
|
+
|
|
243
|
+
props = defn['properties']
|
|
244
|
+
next unless props.is_a?(Hash) && !props.empty?
|
|
245
|
+
|
|
246
|
+
actual_verb = props.keys.first
|
|
247
|
+
@verbs[actual_verb] = {
|
|
248
|
+
'name' => actual_verb,
|
|
249
|
+
'schema_name' => schema_name,
|
|
250
|
+
'definition' => defn
|
|
251
|
+
}
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def init_full_validator
|
|
256
|
+
# Reserved for full-validator wiring (json_schemer gem).
|
|
257
|
+
@full_validator = nil
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def validate_verb_full(verb_name, verb_config)
|
|
261
|
+
# Reserved for full-validator wiring; falls back to lightweight check.
|
|
262
|
+
validate_verb_lightweight(verb_name, verb_config)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def validate_verb_lightweight(verb_name, verb_config)
|
|
266
|
+
errors = []
|
|
267
|
+
get_verb_required_properties(verb_name).each do |prop|
|
|
268
|
+
unless verb_config.key?(prop)
|
|
269
|
+
errors << "Missing required property '#{prop}' for verb '#{verb_name}'"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
[errors.empty?, errors]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def python_type_annotation(defn)
|
|
276
|
+
return 'Any' unless defn.is_a?(Hash)
|
|
277
|
+
|
|
278
|
+
case defn['type']
|
|
279
|
+
when 'string' then 'str'
|
|
280
|
+
when 'integer' then 'int'
|
|
281
|
+
when 'number' then 'float'
|
|
282
|
+
when 'boolean' then 'bool'
|
|
283
|
+
when 'array'
|
|
284
|
+
item = 'Any'
|
|
285
|
+
if defn['items'].is_a?(Hash)
|
|
286
|
+
item = python_type_annotation(defn['items'])
|
|
287
|
+
end
|
|
288
|
+
"List[#{item}]"
|
|
289
|
+
when 'object' then 'Dict[str, Any]'
|
|
290
|
+
else
|
|
291
|
+
'Any'
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
require 'set'
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../core/logging_config'
|
|
4
|
+
|
|
5
|
+
# Cross-language SDK contract: SignalWire::Utils.is_serverless_mode
|
|
6
|
+
# mirrors signalwire.utils.is_serverless_mode in the Python reference.
|
|
7
|
+
# Returns true when running inside any short-lived / event-driven
|
|
8
|
+
# environment (i.e. not 'server').
|
|
9
|
+
|
|
10
|
+
module SignalWire
|
|
11
|
+
module Utils
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @return [Boolean] true unless the detected mode is 'server'.
|
|
15
|
+
def is_serverless_mode
|
|
16
|
+
SignalWire::Core::LoggingConfig.get_execution_mode != 'server'
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2025 SignalWire
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the MIT License.
|
|
6
|
+
# See LICENSE file in the project root for full license information.
|
|
7
|
+
|
|
8
|
+
require 'ipaddr'
|
|
9
|
+
require 'resolv'
|
|
10
|
+
require 'uri'
|
|
11
|
+
|
|
12
|
+
require_relative '../logging'
|
|
13
|
+
|
|
14
|
+
module SignalWire
|
|
15
|
+
module Utils
|
|
16
|
+
# SSRF-prevention guard for user-supplied URLs.
|
|
17
|
+
#
|
|
18
|
+
# Mirrors Python's signalwire.utils.url_validator.validate_url:
|
|
19
|
+
# rejects non-http(s) schemes, missing hostnames, and any URL whose
|
|
20
|
+
# hostname resolves to a private / loopback / link-local / cloud-
|
|
21
|
+
# metadata IP. When +allow_private+ is true, OR the
|
|
22
|
+
# +SWML_ALLOW_PRIVATE_URLS+ env var is set to "1", "true" or "yes"
|
|
23
|
+
# (case-insensitive), the IP-blocklist check is skipped.
|
|
24
|
+
#
|
|
25
|
+
# The method UrlValidator.validate_url projects onto the Python free
|
|
26
|
+
# function signalwire.utils.url_validator.validate_url via
|
|
27
|
+
# scripts/enumerate_signatures.py.
|
|
28
|
+
module UrlValidator
|
|
29
|
+
# Cross-port SSRF block list. Order matches the Python reference.
|
|
30
|
+
BLOCKED_NETWORKS = %w[
|
|
31
|
+
10.0.0.0/8
|
|
32
|
+
172.16.0.0/12
|
|
33
|
+
192.168.0.0/16
|
|
34
|
+
127.0.0.0/8
|
|
35
|
+
169.254.0.0/16
|
|
36
|
+
0.0.0.0/8
|
|
37
|
+
::1/128
|
|
38
|
+
fc00::/7
|
|
39
|
+
fe80::/10
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
LOG = SignalWire::Logging.logger('signalwire.url_validator')
|
|
43
|
+
|
|
44
|
+
# Pluggable resolver hook. Tests inject a lambda to keep the suite
|
|
45
|
+
# hermetic; production calls Resolv.getaddresses. Underscore prefix
|
|
46
|
+
# keeps it out of the public surface inventory — the Python
|
|
47
|
+
# reference only exposes ``validate_url`` at this module level.
|
|
48
|
+
def self._resolver
|
|
49
|
+
@_resolver
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self._resolver=(value)
|
|
53
|
+
@_resolver = value
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Validate that a URL is safe to fetch.
|
|
57
|
+
#
|
|
58
|
+
# @param url [String] URL to validate
|
|
59
|
+
# @param allow_private [Boolean] when true, bypass the IP-blocklist check
|
|
60
|
+
# @return [Boolean] true if the URL is safe to fetch
|
|
61
|
+
def self.validate_url(url, allow_private = false)
|
|
62
|
+
parsed = URI.parse(url)
|
|
63
|
+
|
|
64
|
+
scheme = (parsed.scheme || '').downcase
|
|
65
|
+
unless %w[http https].include?(scheme)
|
|
66
|
+
LOG.warn("URL rejected: invalid scheme #{parsed.scheme}")
|
|
67
|
+
return false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
hostname = parsed.host
|
|
71
|
+
if hostname.nil? || hostname.empty?
|
|
72
|
+
LOG.warn('URL rejected: no hostname')
|
|
73
|
+
return false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# URI keeps brackets in host for IPv6 literals; strip them.
|
|
77
|
+
if hostname.start_with?('[') && hostname.end_with?(']')
|
|
78
|
+
hostname = hostname[1..-2]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if allow_private || _env_allows_private?
|
|
82
|
+
return true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
ips = _resolve(hostname)
|
|
86
|
+
if ips.nil? || ips.empty?
|
|
87
|
+
LOG.warn("URL rejected: could not resolve hostname #{hostname}")
|
|
88
|
+
return false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
ips.each do |ip_str|
|
|
92
|
+
ip = begin
|
|
93
|
+
IPAddr.new(ip_str)
|
|
94
|
+
rescue IPAddr::InvalidAddressError
|
|
95
|
+
next
|
|
96
|
+
end
|
|
97
|
+
BLOCKED_NETWORKS.each do |cidr|
|
|
98
|
+
net = IPAddr.new(cidr)
|
|
99
|
+
if net.include?(ip)
|
|
100
|
+
LOG.warn("URL rejected: #{hostname} resolves to blocked IP #{ip_str} (in #{cidr})")
|
|
101
|
+
return false
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
true
|
|
107
|
+
rescue URI::InvalidURIError, IPAddr::InvalidAddressError => e
|
|
108
|
+
LOG.warn("URL validation error: #{e.message}")
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self._env_allows_private?
|
|
113
|
+
v = (ENV['SWML_ALLOW_PRIVATE_URLS'] || '').downcase
|
|
114
|
+
%w[1 true yes].include?(v)
|
|
115
|
+
end
|
|
116
|
+
private_class_method :_env_allows_private?
|
|
117
|
+
|
|
118
|
+
# @param hostname [String]
|
|
119
|
+
# @return [Array<String>, nil]
|
|
120
|
+
def self._resolve(hostname)
|
|
121
|
+
if _resolver
|
|
122
|
+
return _resolver.call(hostname)
|
|
123
|
+
end
|
|
124
|
+
# Literal IP shortcut (covers tests that pass IP-as-hostname URLs).
|
|
125
|
+
begin
|
|
126
|
+
IPAddr.new(hostname)
|
|
127
|
+
return [hostname]
|
|
128
|
+
rescue IPAddr::InvalidAddressError
|
|
129
|
+
# not a literal IP — fall through to DNS
|
|
130
|
+
end
|
|
131
|
+
Resolv.getaddresses(hostname)
|
|
132
|
+
rescue StandardError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
private_class_method :_resolve
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
data/lib/signalwire.rb
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'signalwire/version'
|
|
4
|
+
require_relative 'signalwire/logging'
|
|
5
|
+
require_relative 'signalwire/core/logging_config'
|
|
6
|
+
require_relative 'signalwire/utils/serverless'
|
|
7
|
+
require_relative 'signalwire/utils/schema_utils'
|
|
8
|
+
require_relative 'signalwire/runtime'
|
|
9
|
+
require_relative 'signalwire/swml/document'
|
|
10
|
+
require_relative 'signalwire/swml/schema'
|
|
11
|
+
require_relative 'signalwire/swml/service'
|
|
12
|
+
require_relative 'signalwire/swaig/function_result'
|
|
13
|
+
require_relative 'signalwire/security/session_manager'
|
|
14
|
+
require_relative 'signalwire/contexts/context_builder'
|
|
15
|
+
require_relative 'signalwire/pom/prompt_object_model'
|
|
16
|
+
require_relative 'signalwire/datamap/data_map'
|
|
17
|
+
require_relative 'signalwire/skills/skill_base'
|
|
18
|
+
require_relative 'signalwire/skills/skill_manager'
|
|
19
|
+
require_relative 'signalwire/skills/skill_registry'
|
|
20
|
+
require_relative 'signalwire/agent/agent_base'
|
|
21
|
+
require_relative 'signalwire/serverless/lambda_handler'
|
|
22
|
+
|
|
23
|
+
module SignalWire
|
|
24
|
+
# Top-level convenience entry points — mirror Python's
|
|
25
|
+
# ``signalwire/__init__.py`` factory + skill registry helpers.
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Construct a {SignalWire::REST::RestClient} instance.
|
|
30
|
+
#
|
|
31
|
+
# Mirrors Python's top-level ``signalwire.RestClient(*args, **kwargs)``
|
|
32
|
+
# factory — a thin wrapper that lazy-imports
|
|
33
|
+
# ``signalwire.rest.RestClient`` and instantiates it. Supports both
|
|
34
|
+
# positional credentials (matching Go-style ``RestClient(project,
|
|
35
|
+
# token, host)``) and keyword credentials (Ruby-idiomatic).
|
|
36
|
+
#
|
|
37
|
+
# @param args [Array<String>] Positional credentials (compat shim).
|
|
38
|
+
# @param kwargs [Hash] Keyword credentials forwarded to the constructor.
|
|
39
|
+
# @return [SignalWire::REST::RestClient]
|
|
40
|
+
def RestClient(*args, **kwargs)
|
|
41
|
+
require_relative 'signalwire/rest/rest_client'
|
|
42
|
+
if args.length >= 3 && kwargs.empty?
|
|
43
|
+
REST::RestClient.new(project: args[0], token: args[1], host: args[2])
|
|
44
|
+
elsif !args.empty? && kwargs.empty?
|
|
45
|
+
raise ArgumentError, 'positional form requires (project, token, host)'
|
|
46
|
+
else
|
|
47
|
+
REST::RestClient.new(**kwargs)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Register a custom skill class with the global skill registry.
|
|
52
|
+
#
|
|
53
|
+
# Mirrors Python's ``signalwire.register_skill(skill_class)`` — the
|
|
54
|
+
# Ruby singleton registry stores factories by name. The class is
|
|
55
|
+
# expected to expose a ``::skill_name`` (or ``SKILL_NAME`` constant)
|
|
56
|
+
# so we can derive the registration key.
|
|
57
|
+
#
|
|
58
|
+
# @param skill_class [Class] A subclass of {SignalWire::Skills::SkillBase}
|
|
59
|
+
# @return [void]
|
|
60
|
+
def register_skill(skill_class)
|
|
61
|
+
require_relative 'signalwire/skills/skill_registry'
|
|
62
|
+
name = if skill_class.respond_to?(:skill_name)
|
|
63
|
+
skill_class.skill_name
|
|
64
|
+
elsif skill_class.const_defined?(:SKILL_NAME)
|
|
65
|
+
skill_class.const_get(:SKILL_NAME)
|
|
66
|
+
else
|
|
67
|
+
raise ArgumentError,
|
|
68
|
+
"skill class #{skill_class} must define ::skill_name or SKILL_NAME"
|
|
69
|
+
end
|
|
70
|
+
Skills::SkillRegistry.register_skill(name, ->(params) { skill_class.new(params) })
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Add a directory to search for skills.
|
|
74
|
+
#
|
|
75
|
+
# Mirrors Python's ``signalwire.add_skill_directory(path)`` — delegates
|
|
76
|
+
# to the singleton {SignalWire::Skills::SkillRegistry} instance so
|
|
77
|
+
# third-party skill collections can be registered by path. Subsequent
|
|
78
|
+
# calls accumulate (de-duplicated) into a shared external paths list.
|
|
79
|
+
#
|
|
80
|
+
# @param path [String] absolute or relative path to a skill directory.
|
|
81
|
+
# @return [void]
|
|
82
|
+
# @raise [ArgumentError] when the path doesn't exist or isn't a directory.
|
|
83
|
+
def add_skill_directory(path)
|
|
84
|
+
require_relative 'signalwire/skills/skill_registry'
|
|
85
|
+
_signalwire_singleton_registry.add_skill_directory(path)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Get complete schema for all available skills, including parameter metadata.
|
|
89
|
+
#
|
|
90
|
+
# Mirrors Python's ``signalwire.list_skills_with_params()``. Keys are
|
|
91
|
+
# skill names; values describe metadata + parameter schema. Useful for
|
|
92
|
+
# GUI configuration tools, API documentation, or programmatic skill
|
|
93
|
+
# discovery.
|
|
94
|
+
#
|
|
95
|
+
# @return [Hash{String => Hash}]
|
|
96
|
+
def list_skills_with_params
|
|
97
|
+
require_relative 'signalwire/skills/skill_registry'
|
|
98
|
+
if Skills::SkillRegistry.respond_to?(:get_all_skills_schema)
|
|
99
|
+
Skills::SkillRegistry.get_all_skills_schema
|
|
100
|
+
else
|
|
101
|
+
# Fallback: list_skills returns names; pair them with empty params.
|
|
102
|
+
Skills::SkillRegistry.list_skills.each_with_object({}) do |name, h|
|
|
103
|
+
h[name] = { 'name' => name, 'parameters' => {} }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Singleton SkillRegistry instance used by the top-level helpers.
|
|
109
|
+
# Internal — exposed only so the helpers can share state across calls.
|
|
110
|
+
def _signalwire_singleton_registry
|
|
111
|
+
@_signalwire_singleton_registry ||= Skills::SkillRegistry.new
|
|
112
|
+
end
|
|
113
|
+
private_class_method :_signalwire_singleton_registry
|
|
114
|
+
end
|