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,650 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'rack'
|
|
7
|
+
require_relative '../logging'
|
|
8
|
+
require_relative 'document'
|
|
9
|
+
require_relative 'schema'
|
|
10
|
+
|
|
11
|
+
module SignalWire
|
|
12
|
+
module SWML
|
|
13
|
+
class Service
|
|
14
|
+
# Python parity:
|
|
15
|
+
# - ``name``, ``route``, ``host``, ``port`` — surface from
|
|
16
|
+
# SWMLService.
|
|
17
|
+
# - ``schema_path`` — path to the SWML schema file (or nil to use
|
|
18
|
+
# the gem-bundled default).
|
|
19
|
+
# - ``config_file`` — optional TOML/YAML config file path.
|
|
20
|
+
# - ``schema_validation`` — boolean flag mirroring Python's
|
|
21
|
+
# ``self._schema_validation``. ``SWML_SKIP_SCHEMA_VALIDATION=1``
|
|
22
|
+
# env var forces this to false.
|
|
23
|
+
attr_reader :name, :route, :host, :port,
|
|
24
|
+
:schema_path, :config_file, :schema_validation
|
|
25
|
+
|
|
26
|
+
# @param name [String] Human-readable service name
|
|
27
|
+
# @param route [String] HTTP path this service responds on (default "/")
|
|
28
|
+
# @param host [String] Bind address (default "0.0.0.0")
|
|
29
|
+
# @param port [Integer, nil] Port — falls back to $PORT then 3000
|
|
30
|
+
# @param basic_auth [Array(String,String), nil] Explicit (user, pass) pair
|
|
31
|
+
# Maximum request body size enforced on /swaig and the main route (1 MB).
|
|
32
|
+
SWAIG_FN_NAME = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/.freeze
|
|
33
|
+
|
|
34
|
+
def initialize(name:, route: '/', host: '0.0.0.0', port: nil, basic_auth: nil,
|
|
35
|
+
schema_path: nil, config_file: nil, schema_validation: true)
|
|
36
|
+
@name = name
|
|
37
|
+
@route = route.chomp('/')
|
|
38
|
+
@route = '/' if @route.empty?
|
|
39
|
+
@host = host
|
|
40
|
+
@port = port || Integer(ENV.fetch('PORT', 3000))
|
|
41
|
+
@log = Logging.logger("SWML::Service[#{name}]")
|
|
42
|
+
@document = Document.new
|
|
43
|
+
@routing_callbacks = {}
|
|
44
|
+
@server = nil
|
|
45
|
+
|
|
46
|
+
# Python parity:
|
|
47
|
+
# - ``schema_path`` — explicit path to the SWML schema file.
|
|
48
|
+
# When nil we fall back to the schema bundled with the gem
|
|
49
|
+
# via SWML::Schema.
|
|
50
|
+
# - ``config_file`` — TOML/YAML configuration override file
|
|
51
|
+
# (Python's ``ConfigLoader``). Ruby v1 stashes the path; the
|
|
52
|
+
# loader is wired by AgentBase only when needed.
|
|
53
|
+
# - ``schema_validation`` — when true (default), out-bound SWML
|
|
54
|
+
# is validated against the schema. ``SWML_SKIP_SCHEMA_VALIDATION=1``
|
|
55
|
+
# env var overrides to false (Python parity).
|
|
56
|
+
@schema_path = schema_path
|
|
57
|
+
@config_file = config_file
|
|
58
|
+
@schema_validation = schema_validation && ENV['SWML_SKIP_SCHEMA_VALIDATION'] != '1'
|
|
59
|
+
|
|
60
|
+
# SWAIG tool registry — lifted from AgentBase so any Service (sidecar,
|
|
61
|
+
# non-agent verb host) can register and dispatch SWAIG functions.
|
|
62
|
+
@tools = {} # name => { definition + handler }
|
|
63
|
+
@swaig_functions = {} # name => raw hash (DataMap etc.)
|
|
64
|
+
|
|
65
|
+
# --- auth --------------------------------------------------------
|
|
66
|
+
@basic_auth = if basic_auth
|
|
67
|
+
basic_auth
|
|
68
|
+
elsif ENV['SWML_BASIC_AUTH_USER'] && ENV['SWML_BASIC_AUTH_PASSWORD']
|
|
69
|
+
[ENV['SWML_BASIC_AUTH_USER'], ENV['SWML_BASIC_AUTH_PASSWORD']]
|
|
70
|
+
else
|
|
71
|
+
[SecureRandom.uuid, SecureRandom.uuid]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@log.info "Service '#{@name}' initialised (route=#{@route}, port=#{@port})"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# SWAIG tool registry (lifted from AgentBase)
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
# Define a SWAIG function the AI can call. Tool descriptions and
|
|
82
|
+
# parameter descriptions are LLM-facing prompt engineering — see
|
|
83
|
+
# PORTING_GUIDE for guidance.
|
|
84
|
+
def define_tool(name:, description:, parameters: {}, secure: false, &handler)
|
|
85
|
+
@tools[name] = {
|
|
86
|
+
definition: {
|
|
87
|
+
'function' => name,
|
|
88
|
+
'description' => description,
|
|
89
|
+
'parameters' => parameters,
|
|
90
|
+
},
|
|
91
|
+
handler: handler,
|
|
92
|
+
secure: secure,
|
|
93
|
+
}
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Register a raw SWAIG function definition (e.g. from DataMap#to_swaig_function).
|
|
98
|
+
def register_swaig_function(func_def)
|
|
99
|
+
fname = func_def['function'] || func_def[:function]
|
|
100
|
+
return self unless fname
|
|
101
|
+
@swaig_functions[fname] = func_def.transform_keys(&:to_s)
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Return an array of all tool definitions (for SWML rendering).
|
|
106
|
+
def define_tools
|
|
107
|
+
defs = @tools.values.map { |t| t[:definition].dup }
|
|
108
|
+
defs + @swaig_functions.values.map(&:dup)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Dispatch a function call to the registered handler. Default plain
|
|
112
|
+
# implementation — AgentBase overrides with token validation.
|
|
113
|
+
def on_function_call(name, args, raw_data)
|
|
114
|
+
tool = @tools[name]
|
|
115
|
+
return nil unless tool && tool[:handler]
|
|
116
|
+
result = tool[:handler].call(args, raw_data)
|
|
117
|
+
if result.is_a?(Hash)
|
|
118
|
+
result
|
|
119
|
+
elsif result.respond_to?(:to_h) && !result.nil?
|
|
120
|
+
result.to_h
|
|
121
|
+
else
|
|
122
|
+
{ 'response' => result.to_s }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# List registered SWAIG tool names in registration order.
|
|
127
|
+
def list_tool_names
|
|
128
|
+
@tools.keys
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Whether a SWAIG function with the given name is registered.
|
|
132
|
+
# (Python parity: ToolRegistry#has_function.)
|
|
133
|
+
def has_function(name)
|
|
134
|
+
@tools.key?(name) || @swaig_functions.key?(name)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Get a registered SWAIG function by name, or nil when absent.
|
|
138
|
+
# (Python parity: ToolRegistry#get_function.)
|
|
139
|
+
def get_function(name)
|
|
140
|
+
@tools[name] || @swaig_functions[name]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Snapshot of all registered SWAIG functions keyed by name.
|
|
144
|
+
# (Python parity: ToolRegistry#get_all_functions.)
|
|
145
|
+
def get_all_functions
|
|
146
|
+
out = {}
|
|
147
|
+
@tools.each { |k, v| out[k] = v }
|
|
148
|
+
@swaig_functions.each { |k, v| out[k] = v }
|
|
149
|
+
out
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Remove a registered SWAIG function. Returns true on success,
|
|
153
|
+
# false when the function was not registered.
|
|
154
|
+
# (Python parity: ToolRegistry#remove_function.)
|
|
155
|
+
def remove_function(name)
|
|
156
|
+
if @tools.key?(name)
|
|
157
|
+
@tools.delete(name)
|
|
158
|
+
true
|
|
159
|
+
elsif @swaig_functions.key?(name)
|
|
160
|
+
@swaig_functions.delete(name)
|
|
161
|
+
true
|
|
162
|
+
else
|
|
163
|
+
false
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Extension point: invoked between argument parsing and function
|
|
168
|
+
# dispatch on POST /swaig. Returns [target, short_circuit]. If
|
|
169
|
+
# short_circuit is non-nil, it's returned as the SWAIG response
|
|
170
|
+
# without calling on_function_call. AgentBase overrides to add
|
|
171
|
+
# session-token validation and ephemeral dynamic-config copies.
|
|
172
|
+
def swaig_pre_dispatch(_request_data, _func_name, _env)
|
|
173
|
+
[self, nil]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Extension point: handle GET /swaig (returns the SWML document by
|
|
177
|
+
# default). AgentBase overrides to render with prompts + dynamic config.
|
|
178
|
+
def render_main_swml(_request_data = nil, request: nil)
|
|
179
|
+
@document.to_h
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Extension point: register additional Rack routes after Service
|
|
183
|
+
# mounts /health, /ready, /swaig, and the main route. AgentBase uses
|
|
184
|
+
# this to add /post_prompt, /debug_events, /mcp.
|
|
185
|
+
#
|
|
186
|
+
# @param sub_path [String] The sub-path under the main route
|
|
187
|
+
# @param request_data [Hash, nil] Parsed JSON body
|
|
188
|
+
# @param env [Hash] The Rack env
|
|
189
|
+
# @return [Array, nil] A Rack response triple, or nil if not handled
|
|
190
|
+
def handle_additional_route(_sub_path, _request_data, _env)
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
# Verb auto-vivification via method_missing
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def method_missing(method_name, *args, **kwargs)
|
|
199
|
+
verb = method_name.to_s
|
|
200
|
+
|
|
201
|
+
if SWML.schema.valid_verb?(verb)
|
|
202
|
+
execute_verb(verb, args, kwargs)
|
|
203
|
+
else
|
|
204
|
+
super
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
209
|
+
SWML.schema.valid_verb?(method_name.to_s) || super
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Execute a SWML verb, adding it to the current document.
|
|
213
|
+
#
|
|
214
|
+
# For most verbs the config is a keyword-args Hash.
|
|
215
|
+
# The +sleep+ verb is special: it also accepts a bare Integer.
|
|
216
|
+
def execute_verb(verb_name, args = [], kwargs = {})
|
|
217
|
+
verb_name = verb_name.to_s
|
|
218
|
+
|
|
219
|
+
if verb_name == 'sleep'
|
|
220
|
+
# Accept sleep(2000) or sleep(duration: 2000)
|
|
221
|
+
value = if args.length == 1 && args.first.is_a?(Integer)
|
|
222
|
+
args.first
|
|
223
|
+
elsif kwargs.key?(:duration)
|
|
224
|
+
kwargs[:duration]
|
|
225
|
+
elsif !kwargs.empty?
|
|
226
|
+
kwargs.values.first
|
|
227
|
+
else
|
|
228
|
+
raise ArgumentError, "sleep requires an integer duration"
|
|
229
|
+
end
|
|
230
|
+
@document.add_verb(verb_name, value)
|
|
231
|
+
else
|
|
232
|
+
config = kwargs.transform_keys(&:to_s).reject { |_, v| v.nil? }
|
|
233
|
+
@document.add_verb(verb_name, config)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------
|
|
238
|
+
# Auth helpers
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
# Get the configured basic-auth credentials.
|
|
242
|
+
#
|
|
243
|
+
# Python parity: ``get_basic_auth_credentials(include_source=False)``.
|
|
244
|
+
# When ``include_source`` is true, returns a 3-tuple ``[user,
|
|
245
|
+
# pass, source]`` where ``source`` is one of ``"environment"``,
|
|
246
|
+
# ``"auto-generated"``, or ``"provided"``. Otherwise returns the
|
|
247
|
+
# 2-tuple ``[user, pass]``.
|
|
248
|
+
#
|
|
249
|
+
# @param include_source [Boolean]
|
|
250
|
+
# @return [Array(String, String)] or [Array(String, String, String)]
|
|
251
|
+
def get_basic_auth_credentials(include_source: false)
|
|
252
|
+
u, p = @basic_auth
|
|
253
|
+
return [u, p] unless include_source
|
|
254
|
+
|
|
255
|
+
env_user = ENV['SWML_BASIC_AUTH_USER']
|
|
256
|
+
env_pass = ENV['SWML_BASIC_AUTH_PASSWORD']
|
|
257
|
+
source =
|
|
258
|
+
if env_user && !env_user.empty? && env_pass && !env_pass.empty? && u == env_user && p == env_pass
|
|
259
|
+
'environment'
|
|
260
|
+
elsif u&.start_with?('user_') && p && p.length > 20
|
|
261
|
+
'auto-generated'
|
|
262
|
+
else
|
|
263
|
+
'provided'
|
|
264
|
+
end
|
|
265
|
+
[u, p, source]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Validate provided basic-auth credentials against the configured ones
|
|
269
|
+
# using a constant-time comparison.
|
|
270
|
+
# Python parity: AuthMixin#validate_basic_auth(username, password).
|
|
271
|
+
def validate_basic_auth(username, password)
|
|
272
|
+
require 'openssl'
|
|
273
|
+
u, p = @basic_auth
|
|
274
|
+
return false if u.nil? || p.nil?
|
|
275
|
+
OpenSSL.fixed_length_secure_compare(username, u) &&
|
|
276
|
+
OpenSSL.fixed_length_secure_compare(password, p)
|
|
277
|
+
rescue ArgumentError
|
|
278
|
+
# fixed_length_secure_compare raises on length mismatch
|
|
279
|
+
false
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Backwards-compat alias for the legacy 3-tuple-only form.
|
|
283
|
+
# @return [Array(String, String, String)]
|
|
284
|
+
def get_basic_auth_credentials_with_source
|
|
285
|
+
get_basic_auth_credentials(include_source: true)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Build the full URL for this service.
|
|
289
|
+
#
|
|
290
|
+
# get_full_url # => "http://0.0.0.0:3000/"
|
|
291
|
+
# get_full_url(include_auth: true) # => "http://user:pass@0.0.0.0:3000/"
|
|
292
|
+
def get_full_url(include_auth: false)
|
|
293
|
+
scheme = 'http'
|
|
294
|
+
auth = include_auth ? "#{@basic_auth[0]}:#{@basic_auth[1]}@" : ''
|
|
295
|
+
path = @route == '/' ? '/' : @route
|
|
296
|
+
"#{scheme}://#{auth}#{@host}:#{@port}#{path}"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# ------------------------------------------------------------------
|
|
300
|
+
# Routing callbacks & request handling
|
|
301
|
+
# ------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
def register_routing_callback(path, &block)
|
|
304
|
+
@routing_callbacks[path] = block
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Customization hook called when SWML is requested. Default
|
|
308
|
+
# delegates to {#on_swml_request} and returns its result.
|
|
309
|
+
# Subclasses typically override +on_swml_request+ rather than
|
|
310
|
+
# this method.
|
|
311
|
+
#
|
|
312
|
+
# Return +nil+ to use the default SWML rendering, or a Hash of
|
|
313
|
+
# modifications to merge into the document.
|
|
314
|
+
#
|
|
315
|
+
# Python parity: WebMixin#on_request(request_data, callback_path).
|
|
316
|
+
# The Python third +request+ argument is FastAPI-specific and
|
|
317
|
+
# intentionally not mirrored.
|
|
318
|
+
# Python parity: ``on_request(request_data, callback_path)``. The
|
|
319
|
+
# third Python parameter (``request``) — a FastAPI ``Request`` —
|
|
320
|
+
# is propagated through Ruby as the optional ``request:`` keyword
|
|
321
|
+
# so subclasses can read query/header info when a Rack-style
|
|
322
|
+
# request is available. Default: delegate to ``on_swml_request``.
|
|
323
|
+
def on_request(request_data = nil, callback_path = nil, request: nil)
|
|
324
|
+
on_swml_request(request_data, callback_path, request: request)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Customization point for subclasses to modify SWML based on
|
|
328
|
+
# request data. The default returns nil (no modification).
|
|
329
|
+
#
|
|
330
|
+
# Python parity:
|
|
331
|
+
# ``on_swml_request(request_data, callback_path, request)``. The
|
|
332
|
+
# ``request:`` keyword carries the Rack request (or FastAPI
|
|
333
|
+
# ``Request`` analogue) for subclasses that need query params
|
|
334
|
+
# or headers.
|
|
335
|
+
def on_swml_request(request_data = nil, callback_path = nil, request: nil)
|
|
336
|
+
nil
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# ------------------------------------------------------------------
|
|
340
|
+
# Render the current SWML document
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
def render
|
|
344
|
+
@document.render
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def render_pretty
|
|
348
|
+
@document.render_pretty
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Expose the underlying document (useful for tests and subclasses).
|
|
352
|
+
def document
|
|
353
|
+
@document
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# SchemaUtils helper bound to this Service. Mirrors Python's
|
|
357
|
+
# self.schema_utils public instance attribute on SWMLService.
|
|
358
|
+
# Built lazily on first access.
|
|
359
|
+
def schema_utils
|
|
360
|
+
@schema_utils ||= begin
|
|
361
|
+
require_relative '../utils/schema_utils'
|
|
362
|
+
::SignalWire::Utils::SchemaUtils.new
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# ------------------------------------------------------------------
|
|
367
|
+
# Rack interface
|
|
368
|
+
# ------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
# Returns a Rack-compatible application.
|
|
371
|
+
def rack_app
|
|
372
|
+
@rack_app ||= build_rack_app
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Start serving (blocking).
|
|
376
|
+
#
|
|
377
|
+
# Python parity:
|
|
378
|
+
# ``serve(host=None, port=None, ssl_cert=None, ssl_key=None,
|
|
379
|
+
# ssl_enabled=None, domain=None)``. When SSL parameters are
|
|
380
|
+
# supplied the server is started with HTTPS bindings; otherwise
|
|
381
|
+
# plain HTTP. ``host``/``port`` overrides default to the
|
|
382
|
+
# constructor-provided values.
|
|
383
|
+
#
|
|
384
|
+
# @param host [String, nil] override bind host
|
|
385
|
+
# @param port [Integer, nil] override bind port
|
|
386
|
+
# @param ssl_cert [String, nil] PEM cert path
|
|
387
|
+
# @param ssl_key [String, nil] PEM key path
|
|
388
|
+
# @param ssl_enabled [Boolean, nil] explicit SSL enable
|
|
389
|
+
# @param domain [String, nil] domain for SSL config
|
|
390
|
+
def serve(host: nil, port: nil, ssl_cert: nil, ssl_key: nil,
|
|
391
|
+
ssl_enabled: nil, domain: nil)
|
|
392
|
+
require 'webrick'
|
|
393
|
+
|
|
394
|
+
bind_host = host || @host
|
|
395
|
+
bind_port = port || @port
|
|
396
|
+
|
|
397
|
+
if !ssl_enabled.nil?
|
|
398
|
+
@ssl_enabled = ssl_enabled
|
|
399
|
+
end
|
|
400
|
+
@domain = domain if domain
|
|
401
|
+
@ssl_cert_path = ssl_cert if ssl_cert
|
|
402
|
+
@ssl_key_path = ssl_key if ssl_key
|
|
403
|
+
|
|
404
|
+
@log.info "Starting server on #{bind_host}:#{bind_port} ..."
|
|
405
|
+
|
|
406
|
+
user, _pass = @basic_auth
|
|
407
|
+
@log.info "Basic-auth credentials — user: #{user} password: [REDACTED]"
|
|
408
|
+
|
|
409
|
+
webrick_opts = {
|
|
410
|
+
Host: bind_host,
|
|
411
|
+
Port: bind_port,
|
|
412
|
+
Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN),
|
|
413
|
+
AccessLog: []
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if @ssl_enabled && @ssl_cert_path && @ssl_key_path
|
|
417
|
+
require 'webrick/https'
|
|
418
|
+
require 'openssl'
|
|
419
|
+
webrick_opts[:SSLEnable] = true
|
|
420
|
+
webrick_opts[:SSLCertificate] = OpenSSL::X509::Certificate.new(File.read(@ssl_cert_path))
|
|
421
|
+
webrick_opts[:SSLPrivateKey] = OpenSSL::PKey::RSA.new(File.read(@ssl_key_path))
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
@server = ::WEBrick::HTTPServer.new(**webrick_opts)
|
|
425
|
+
|
|
426
|
+
# Rack 3+ moved Handler to the rackup gem.
|
|
427
|
+
handler = begin
|
|
428
|
+
require 'rackup/handler/webrick'
|
|
429
|
+
Rackup::Handler::WEBrick
|
|
430
|
+
rescue LoadError
|
|
431
|
+
require 'rack/handler/webrick'
|
|
432
|
+
Rack::Handler::WEBrick
|
|
433
|
+
end
|
|
434
|
+
@server.mount '/', handler, rack_app
|
|
435
|
+
|
|
436
|
+
trap('INT') { stop }
|
|
437
|
+
trap('TERM') { stop }
|
|
438
|
+
|
|
439
|
+
@server.start
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Gracefully stop the server.
|
|
443
|
+
def stop
|
|
444
|
+
@server&.shutdown
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# ------------------------------------------------------------------
|
|
448
|
+
private
|
|
449
|
+
# ------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
# Internal request dispatcher: invoked by the rack app to produce
|
|
452
|
+
# the final SWML hash for a request. Tries (in order) the
|
|
453
|
+
# +on_request+ customization hook (Python WebMixin parity), then
|
|
454
|
+
# any registered routing callback, then the default rendered
|
|
455
|
+
# document.
|
|
456
|
+
#
|
|
457
|
+
# +request_data+ is the parsed JSON body (or nil). Returns the
|
|
458
|
+
# SWML hash to serialise as the response.
|
|
459
|
+
def dispatch_request(request_data, callback_path)
|
|
460
|
+
override = on_request(request_data, callback_path)
|
|
461
|
+
return override if override.is_a?(Hash) && !override.empty?
|
|
462
|
+
|
|
463
|
+
if @routing_callbacks.key?(callback_path)
|
|
464
|
+
@routing_callbacks[callback_path].call(request_data)
|
|
465
|
+
else
|
|
466
|
+
@document.to_h
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def build_rack_app
|
|
471
|
+
service = self
|
|
472
|
+
main_route = @route
|
|
473
|
+
|
|
474
|
+
app = Rack::Builder.new do
|
|
475
|
+
# --- public endpoints (no auth) --------------------------------
|
|
476
|
+
map '/health' do
|
|
477
|
+
run ->(_env) {
|
|
478
|
+
body = JSON.generate({ status: 'healthy' })
|
|
479
|
+
[200, { 'content-type' => 'application/json' }, [body]]
|
|
480
|
+
}
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
map '/ready' do
|
|
484
|
+
run ->(_env) {
|
|
485
|
+
body = JSON.generate({ status: 'ready' })
|
|
486
|
+
[200, { 'content-type' => 'application/json' }, [body]]
|
|
487
|
+
}
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# --- authenticated endpoints -----------------------------------
|
|
491
|
+
map main_route do
|
|
492
|
+
use SecurityHeadersMiddleware
|
|
493
|
+
use TimingSafeBasicAuth, service
|
|
494
|
+
|
|
495
|
+
run ->(env) {
|
|
496
|
+
request = Rack::Request.new(env)
|
|
497
|
+
|
|
498
|
+
# Determine sub-path for routing callbacks / additional routes.
|
|
499
|
+
sub_path = env['PATH_INFO'] || '/'
|
|
500
|
+
sub_path = '/' if sub_path.empty?
|
|
501
|
+
|
|
502
|
+
request_data = nil
|
|
503
|
+
if request.post? || request.put?
|
|
504
|
+
body = request.body.read
|
|
505
|
+
request_data = JSON.parse(body) rescue nil
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# /swaig — handled by Service itself (lifted from AgentBase).
|
|
509
|
+
if sub_path == '/swaig'
|
|
510
|
+
next service.send(:_handle_swaig_endpoint, request, request_data, env)
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Subclass extension hook for /post_prompt, /debug_events, /mcp, etc.
|
|
514
|
+
extra = service.handle_additional_route(sub_path, request_data, env)
|
|
515
|
+
next extra if extra
|
|
516
|
+
|
|
517
|
+
# Fallback: customization hook, routing-callback, then SWML doc.
|
|
518
|
+
# Call the private dispatcher via __send__ so subclass overrides
|
|
519
|
+
# of on_request / on_swml_request are honoured normally.
|
|
520
|
+
result = service.__send__(:dispatch_request, request_data, sub_path)
|
|
521
|
+
body = JSON.generate(result)
|
|
522
|
+
[200, { 'content-type' => 'application/json' }, [body]]
|
|
523
|
+
}
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
app
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Internal: handle GET/POST /swaig.
|
|
531
|
+
# GET — returns the rendered SWML doc via render_main_swml.
|
|
532
|
+
# POST — parses {function, argument, call_id}, validates, runs the
|
|
533
|
+
# swaig_pre_dispatch hook, dispatches via on_function_call.
|
|
534
|
+
def _handle_swaig_endpoint(request, request_data, env)
|
|
535
|
+
if request.get?
|
|
536
|
+
swml = render_main_swml(request_data, request: request)
|
|
537
|
+
return [200, { 'content-type' => 'application/json' }, [JSON.generate(swml)]]
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
unless request_data
|
|
541
|
+
return [400, { 'content-type' => 'application/json' },
|
|
542
|
+
[JSON.generate('error' => 'Missing request body')]]
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
func_name = request_data['function']
|
|
546
|
+
if func_name.nil? || func_name.empty?
|
|
547
|
+
return [400, { 'content-type' => 'application/json' },
|
|
548
|
+
[JSON.generate('error' => 'Missing function name')]]
|
|
549
|
+
end
|
|
550
|
+
unless SWAIG_FN_NAME.match?(func_name)
|
|
551
|
+
return [400, { 'content-type' => 'application/json' },
|
|
552
|
+
[JSON.generate('error' => "Invalid function name format: '#{func_name}'")]]
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# Argument extraction: nested {argument:{parsed:[...]}} OR flat {arguments}
|
|
556
|
+
args = {}
|
|
557
|
+
if request_data['argument'].is_a?(Hash)
|
|
558
|
+
parsed = request_data['argument']['parsed']
|
|
559
|
+
args = parsed.first if parsed.is_a?(Array) && !parsed.empty?
|
|
560
|
+
elsif request_data['arguments'].is_a?(Hash)
|
|
561
|
+
args = request_data['arguments']
|
|
562
|
+
end
|
|
563
|
+
args ||= {}
|
|
564
|
+
|
|
565
|
+
target, short_circuit = swaig_pre_dispatch(request_data, func_name, env)
|
|
566
|
+
if short_circuit
|
|
567
|
+
return [200, { 'content-type' => 'application/json' }, [JSON.generate(short_circuit)]]
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
result = target.on_function_call(func_name, args, request_data)
|
|
571
|
+
if result.nil?
|
|
572
|
+
return [404, { 'content-type' => 'application/json' },
|
|
573
|
+
[JSON.generate('error' => "Unknown function: #{func_name}")]]
|
|
574
|
+
end
|
|
575
|
+
[200, { 'content-type' => 'application/json' }, [JSON.generate(result)]]
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# ------------------------------------------------------------------
|
|
579
|
+
# Middleware: security headers
|
|
580
|
+
# ------------------------------------------------------------------
|
|
581
|
+
class SecurityHeadersMiddleware
|
|
582
|
+
HEADERS = {
|
|
583
|
+
'x-content-type-options' => 'nosniff',
|
|
584
|
+
'x-frame-options' => 'DENY',
|
|
585
|
+
'cache-control' => 'no-store, no-cache, must-revalidate'
|
|
586
|
+
}.freeze
|
|
587
|
+
|
|
588
|
+
def initialize(app)
|
|
589
|
+
@app = app
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def call(env)
|
|
593
|
+
status, headers, body = @app.call(env)
|
|
594
|
+
HEADERS.each { |k, v| headers[k] = v }
|
|
595
|
+
[status, headers, body]
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# ------------------------------------------------------------------
|
|
600
|
+
# Middleware: timing-safe Basic-Auth
|
|
601
|
+
# ------------------------------------------------------------------
|
|
602
|
+
class TimingSafeBasicAuth
|
|
603
|
+
def initialize(app, service)
|
|
604
|
+
@app = app
|
|
605
|
+
@service = service
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def call(env)
|
|
609
|
+
auth = Rack::Auth::Basic::Request.new(env)
|
|
610
|
+
|
|
611
|
+
unless auth.provided? && auth.basic?
|
|
612
|
+
return unauthorized
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
user, pass = @service.get_basic_auth_credentials
|
|
616
|
+
input_user, input_pass = auth.credentials
|
|
617
|
+
|
|
618
|
+
# Timing-safe comparison to prevent timing attacks.
|
|
619
|
+
user_ok = secure_compare(user, input_user)
|
|
620
|
+
pass_ok = secure_compare(pass, input_pass)
|
|
621
|
+
|
|
622
|
+
if user_ok && pass_ok
|
|
623
|
+
@app.call(env)
|
|
624
|
+
else
|
|
625
|
+
unauthorized
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
private
|
|
630
|
+
|
|
631
|
+
def unauthorized
|
|
632
|
+
body = 'Unauthorized'
|
|
633
|
+
[
|
|
634
|
+
401,
|
|
635
|
+
{
|
|
636
|
+
'content-type' => 'text/plain',
|
|
637
|
+
'www-authenticate' => 'Basic realm="SignalWire SWML Service"'
|
|
638
|
+
},
|
|
639
|
+
[body]
|
|
640
|
+
]
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Rack::Utils.secure_compare performs a constant-time byte comparison.
|
|
644
|
+
def secure_compare(a, b)
|
|
645
|
+
Rack::Utils.secure_compare(a.to_s, b.to_s)
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
end
|