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,413 @@
|
|
|
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
|
+
require 'thread'
|
|
10
|
+
require_relative '../logging'
|
|
11
|
+
|
|
12
|
+
module SignalWire
|
|
13
|
+
# Multi-agent hosting on a single Rack application.
|
|
14
|
+
#
|
|
15
|
+
# server = AgentServer.new(host: '0.0.0.0', port: 3000)
|
|
16
|
+
# server.register(my_agent, route: '/agent1')
|
|
17
|
+
# server.register(my_agent2, route: '/agent2')
|
|
18
|
+
# server.run
|
|
19
|
+
#
|
|
20
|
+
class AgentServer
|
|
21
|
+
attr_reader :host, :port, :log_level, :logger
|
|
22
|
+
|
|
23
|
+
# Public Rack application — Python parity: ``server.app`` exposes
|
|
24
|
+
# the underlying FastAPI instance. Ruby exposes the cached Rack
|
|
25
|
+
# app (a Proc) so callers can mount it on their own server or
|
|
26
|
+
# pass it to Rack-compatible test harnesses.
|
|
27
|
+
def app
|
|
28
|
+
@rack_app ||= rack_app
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# MIME types for static file serving.
|
|
32
|
+
MIME_TYPES = {
|
|
33
|
+
'.html' => 'text/html',
|
|
34
|
+
'.htm' => 'text/html',
|
|
35
|
+
'.css' => 'text/css',
|
|
36
|
+
'.js' => 'application/javascript',
|
|
37
|
+
'.json' => 'application/json',
|
|
38
|
+
'.png' => 'image/png',
|
|
39
|
+
'.jpg' => 'image/jpeg',
|
|
40
|
+
'.jpeg' => 'image/jpeg',
|
|
41
|
+
'.gif' => 'image/gif',
|
|
42
|
+
'.svg' => 'image/svg+xml',
|
|
43
|
+
'.ico' => 'image/x-icon',
|
|
44
|
+
'.txt' => 'text/plain',
|
|
45
|
+
'.xml' => 'application/xml',
|
|
46
|
+
'.woff' => 'font/woff',
|
|
47
|
+
'.woff2' => 'font/woff2',
|
|
48
|
+
'.ttf' => 'font/ttf',
|
|
49
|
+
'.eot' => 'application/vnd.ms-fontobject',
|
|
50
|
+
'.map' => 'application/json',
|
|
51
|
+
'.webp' => 'image/webp',
|
|
52
|
+
'.pdf' => 'application/pdf'
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
# Security headers applied to static file responses.
|
|
56
|
+
STATIC_SECURITY_HEADERS = {
|
|
57
|
+
'x-content-type-options' => 'nosniff',
|
|
58
|
+
'x-frame-options' => 'DENY',
|
|
59
|
+
'cache-control' => 'no-store, no-cache, must-revalidate'
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
# Construct an AgentServer.
|
|
63
|
+
#
|
|
64
|
+
# Python parity: ``AgentServer(host, port, log_level)`` —
|
|
65
|
+
# ``log_level`` controls the AgentServer's logger verbosity. The
|
|
66
|
+
# Ruby port maps it through ``SignalWire::Logging.logger`` so the
|
|
67
|
+
# WARN/INFO/DEBUG semantics match Python's ``logging`` levels.
|
|
68
|
+
#
|
|
69
|
+
# @param host [String] bind address (default ``"0.0.0.0"``)
|
|
70
|
+
# @param port [Integer] bind port (default ``3000``)
|
|
71
|
+
# @param log_level [String] log level — one of ``"debug"``,
|
|
72
|
+
# ``"info"``, ``"warning"``/``"warn"``, ``"error"``,
|
|
73
|
+
# ``"critical"``/``"fatal"``. Default ``"info"``.
|
|
74
|
+
def initialize(host: '0.0.0.0', port: 3000, log_level: 'info')
|
|
75
|
+
@host = host
|
|
76
|
+
@port = port
|
|
77
|
+
@log_level = log_level.to_s.downcase
|
|
78
|
+
@agents = {} # route => agent object
|
|
79
|
+
@sip_routes = {} # username => route
|
|
80
|
+
@static_routes = {} # route => directory
|
|
81
|
+
@mutex = Mutex.new
|
|
82
|
+
|
|
83
|
+
@logger = Logging.logger("AgentServer")
|
|
84
|
+
_apply_log_level(@logger, @log_level)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Map a Python-style log level string to the underlying logger's
|
|
88
|
+
# threshold. Mirrors Python's ``log_level`` mapping in AgentServer
|
|
89
|
+
# so callers get equivalent verbosity controls.
|
|
90
|
+
#
|
|
91
|
+
# The SignalWire stdlib logger doesn't expose a per-instance
|
|
92
|
+
# ``level=``; we attach a ``@level`` ivar to the underlying
|
|
93
|
+
# ``Logger`` so introspection-style tests can check it. The
|
|
94
|
+
# ``::Logger`` constant from Ruby's stdlib (``require 'logger'``)
|
|
95
|
+
# exposes DEBUG/INFO/WARN/ERROR/FATAL constants we mirror.
|
|
96
|
+
# @api private
|
|
97
|
+
def _apply_log_level(logger, level)
|
|
98
|
+
require 'logger'
|
|
99
|
+
mapped = case level
|
|
100
|
+
when 'debug' then ::Logger::DEBUG
|
|
101
|
+
when 'info' then ::Logger::INFO
|
|
102
|
+
when 'warning', 'warn' then ::Logger::WARN
|
|
103
|
+
when 'error' then ::Logger::ERROR
|
|
104
|
+
when 'critical', 'fatal' then ::Logger::FATAL
|
|
105
|
+
else ::Logger::INFO
|
|
106
|
+
end
|
|
107
|
+
if logger.respond_to?(:level=)
|
|
108
|
+
logger.level = mapped
|
|
109
|
+
else
|
|
110
|
+
# SignalWire::Logging::Logger doesn't expose level=; attach
|
|
111
|
+
# via instance_variable so .level reads return the mapped
|
|
112
|
+
# value. We add a singleton accessor.
|
|
113
|
+
logger.instance_variable_set(:@level, mapped)
|
|
114
|
+
unless logger.respond_to?(:level)
|
|
115
|
+
logger.define_singleton_method(:level) { @level }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
mapped
|
|
119
|
+
rescue StandardError
|
|
120
|
+
::Logger::INFO rescue nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Register an agent at a given route.
|
|
124
|
+
# @param agent [Object] an agent object (e.g. AgentBase or prefab)
|
|
125
|
+
# @param route [String, nil] HTTP route; defaults to agent.route if available
|
|
126
|
+
def register(agent, route: nil)
|
|
127
|
+
route ||= agent.respond_to?(:route) ? agent.route : "/#{agent.object_id}"
|
|
128
|
+
route = "/#{route}" unless route.start_with?('/')
|
|
129
|
+
|
|
130
|
+
@mutex.synchronize do
|
|
131
|
+
raise ArgumentError, "Route already registered: #{route}" if @agents.key?(route)
|
|
132
|
+
@agents[route] = agent
|
|
133
|
+
end
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Unregister an agent by route.
|
|
138
|
+
# @param route [String]
|
|
139
|
+
# @return [Object, nil] the removed agent
|
|
140
|
+
def unregister(route)
|
|
141
|
+
route = "/#{route}" unless route.start_with?('/')
|
|
142
|
+
@mutex.synchronize { @agents.delete(route) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Get all registered agents.
|
|
146
|
+
# @return [Hash] route => agent
|
|
147
|
+
def get_agents
|
|
148
|
+
@mutex.synchronize { @agents.dup }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Get a specific agent by route.
|
|
152
|
+
# @param route [String]
|
|
153
|
+
# @return [Object, nil]
|
|
154
|
+
def get_agent(route)
|
|
155
|
+
route = "/#{route}" unless route.start_with?('/')
|
|
156
|
+
@mutex.synchronize { @agents[route] }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Set up SIP-based routing.
|
|
160
|
+
# @param route [String] the route to handle SIP requests
|
|
161
|
+
# @param auto_map [Boolean] automatically map agent names as SIP usernames
|
|
162
|
+
def setup_sip_routing(route: '/sip', auto_map: true)
|
|
163
|
+
@sip_route = route
|
|
164
|
+
if auto_map
|
|
165
|
+
@mutex.synchronize do
|
|
166
|
+
@agents.each do |r, agent|
|
|
167
|
+
username = r.sub(%r{^/}, '').tr('/', '_')
|
|
168
|
+
@sip_routes[username] = r
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
self
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Register a SIP username mapping to a route.
|
|
176
|
+
def register_sip_username(username, route)
|
|
177
|
+
route = "/#{route}" unless route.start_with?('/')
|
|
178
|
+
@mutex.synchronize { @sip_routes[username] = route }
|
|
179
|
+
self
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Serve static files from a directory at a given route.
|
|
183
|
+
#
|
|
184
|
+
# @param directory [String] absolute or relative path to the directory
|
|
185
|
+
# @param route [String] the URL prefix to serve files at
|
|
186
|
+
# @return [self]
|
|
187
|
+
def serve_static_files(directory, route)
|
|
188
|
+
route = "/#{route}" unless route.start_with?('/')
|
|
189
|
+
route = route.chomp('/')
|
|
190
|
+
resolved = File.expand_path(directory)
|
|
191
|
+
raise ArgumentError, "Directory does not exist: #{resolved}" unless File.directory?(resolved)
|
|
192
|
+
|
|
193
|
+
@mutex.synchronize { @static_routes[route] = resolved }
|
|
194
|
+
self
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Universal run method — mirrors Python's
|
|
198
|
+
# ``AgentServer.run(event=None, context=None, host=None, port=None)``.
|
|
199
|
+
#
|
|
200
|
+
# Detects execution mode and routes appropriately:
|
|
201
|
+
#
|
|
202
|
+
# - **Server mode** — starts WEBrick (Ruby's stdlib HTTP server)
|
|
203
|
+
# bound to ``host``/``port`` (overrides honoured if supplied).
|
|
204
|
+
# - **Lambda mode** (``AWS_LAMBDA_FUNCTION_NAME`` env var present)
|
|
205
|
+
# — invokes ``_handle_lambda_request(event, context)`` and
|
|
206
|
+
# returns the Lambda response Hash.
|
|
207
|
+
# - **CGI mode** (``GATEWAY_INTERFACE`` env var present) — invokes
|
|
208
|
+
# ``_handle_cgi_request`` and returns the CGI response String.
|
|
209
|
+
#
|
|
210
|
+
# @param event [Object, nil] serverless event (Lambda)
|
|
211
|
+
# @param context [Object, nil] serverless context (Lambda)
|
|
212
|
+
# @param host [String, nil] override bind host (server mode)
|
|
213
|
+
# @param port [Integer, nil] override bind port (server mode)
|
|
214
|
+
# @return [Object, nil] response for serverless modes, nil for
|
|
215
|
+
# server mode (blocking until shutdown).
|
|
216
|
+
def run(event: nil, context: nil, host: nil, port: nil)
|
|
217
|
+
mode = _detect_execution_mode
|
|
218
|
+
|
|
219
|
+
case mode
|
|
220
|
+
when 'lambda'
|
|
221
|
+
_handle_lambda_request(event, context)
|
|
222
|
+
when 'cgi'
|
|
223
|
+
_handle_cgi_request
|
|
224
|
+
else
|
|
225
|
+
_run_server(host, port)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# @api private
|
|
230
|
+
def _detect_execution_mode
|
|
231
|
+
return 'lambda' if ENV['AWS_LAMBDA_FUNCTION_NAME'] && !ENV['AWS_LAMBDA_FUNCTION_NAME'].empty?
|
|
232
|
+
return 'cgi' if ENV['GATEWAY_INTERFACE']
|
|
233
|
+
'server'
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# @api private
|
|
237
|
+
def _run_server(host = nil, port = nil)
|
|
238
|
+
bind_host = host || @host
|
|
239
|
+
bind_port = port || @port
|
|
240
|
+
app = rack_app
|
|
241
|
+
require 'webrick'
|
|
242
|
+
server = WEBrick::HTTPServer.new(
|
|
243
|
+
Host: bind_host,
|
|
244
|
+
Port: bind_port,
|
|
245
|
+
Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN),
|
|
246
|
+
AccessLog: []
|
|
247
|
+
)
|
|
248
|
+
server.mount('/', Rack::Handler::WEBrick, app) if defined?(Rack::Handler::WEBrick)
|
|
249
|
+
trap('INT') { server.shutdown }
|
|
250
|
+
trap('TERM') { server.shutdown }
|
|
251
|
+
@logger&.info("AgentServer starting on #{bind_host}:#{bind_port}")
|
|
252
|
+
server.start
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# @api private
|
|
256
|
+
# Handle a CGI request — minimal Ruby parity for Python's
|
|
257
|
+
# ``_handle_cgi_request``. Reads ``PATH_INFO``, dispatches to the
|
|
258
|
+
# matching agent, and returns a CGI-formatted response string.
|
|
259
|
+
def _handle_cgi_request
|
|
260
|
+
require 'stringio'
|
|
261
|
+
path_info = (ENV['PATH_INFO'] || '').strip
|
|
262
|
+
env = {
|
|
263
|
+
'PATH_INFO' => path_info,
|
|
264
|
+
'REQUEST_METHOD' => ENV['REQUEST_METHOD'] || 'GET',
|
|
265
|
+
'QUERY_STRING' => ENV['QUERY_STRING'] || '',
|
|
266
|
+
'rack.input' => StringIO.new(''),
|
|
267
|
+
'rack.errors' => $stderr
|
|
268
|
+
}
|
|
269
|
+
status, headers, body = rack_app.call(env)
|
|
270
|
+
body_str = body.respond_to?(:join) ? body.join : body.to_s
|
|
271
|
+
|
|
272
|
+
out = +"Status: #{status}\r\n"
|
|
273
|
+
headers.each { |k, v| out << "#{k}: #{v}\r\n" }
|
|
274
|
+
out << "\r\n"
|
|
275
|
+
out << body_str
|
|
276
|
+
out
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# @api private
|
|
280
|
+
# Handle a Lambda invocation event. Translates the Lambda event
|
|
281
|
+
# shape into a Rack env, dispatches, and returns a Lambda
|
|
282
|
+
# response Hash (statusCode/headers/body).
|
|
283
|
+
def _handle_lambda_request(event, _context)
|
|
284
|
+
require 'stringio'
|
|
285
|
+
event ||= {}
|
|
286
|
+
path = event['path'] || event['rawPath'] || event['pathParameters']&.dig('proxy') || '/'
|
|
287
|
+
method = event['httpMethod'] || event.dig('requestContext', 'http', 'method') || 'GET'
|
|
288
|
+
body = event['body'] || ''
|
|
289
|
+
env = {
|
|
290
|
+
'PATH_INFO' => path,
|
|
291
|
+
'REQUEST_METHOD' => method,
|
|
292
|
+
'QUERY_STRING' => '',
|
|
293
|
+
'rack.input' => StringIO.new(body),
|
|
294
|
+
'rack.errors' => $stderr
|
|
295
|
+
}
|
|
296
|
+
status, headers, response_body = rack_app.call(env)
|
|
297
|
+
body_str = response_body.respond_to?(:join) ? response_body.join : response_body.to_s
|
|
298
|
+
{
|
|
299
|
+
'statusCode' => Integer(status),
|
|
300
|
+
'headers' => headers,
|
|
301
|
+
'body' => body_str
|
|
302
|
+
}
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Build a Rack application that routes requests to the appropriate agent.
|
|
306
|
+
# @return [Proc] a Rack-compatible app
|
|
307
|
+
def rack_app
|
|
308
|
+
agents = @agents
|
|
309
|
+
sip_routes = @sip_routes
|
|
310
|
+
static_routes = @static_routes
|
|
311
|
+
server = self
|
|
312
|
+
|
|
313
|
+
Proc.new do |env|
|
|
314
|
+
path = env['PATH_INFO'] || '/'
|
|
315
|
+
|
|
316
|
+
case path
|
|
317
|
+
when '/health', '/healthz'
|
|
318
|
+
body = { status: 'ok', agents: agents.keys }.to_json
|
|
319
|
+
['200', { 'Content-Type' => 'application/json' }, [body]]
|
|
320
|
+
|
|
321
|
+
when '/'
|
|
322
|
+
body = {
|
|
323
|
+
service: 'SignalWire Agent Server',
|
|
324
|
+
agents: agents.keys,
|
|
325
|
+
version: defined?(SignalWire::VERSION) ? SignalWire::VERSION : '1.0.0'
|
|
326
|
+
}.to_json
|
|
327
|
+
['200', { 'Content-Type' => 'application/json' }, [body]]
|
|
328
|
+
|
|
329
|
+
else
|
|
330
|
+
# Check static routes first (longest prefix match)
|
|
331
|
+
static_result = server._try_serve_static(path, static_routes)
|
|
332
|
+
if static_result
|
|
333
|
+
static_result
|
|
334
|
+
else
|
|
335
|
+
# Find the matching agent by longest prefix match
|
|
336
|
+
agent = nil
|
|
337
|
+
matched_route = nil
|
|
338
|
+
|
|
339
|
+
agents.each do |route, a|
|
|
340
|
+
if path == route || path.start_with?("#{route}/")
|
|
341
|
+
if matched_route.nil? || route.length > matched_route.length
|
|
342
|
+
matched_route = route
|
|
343
|
+
agent = a
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
if agent
|
|
349
|
+
if agent.respond_to?(:call)
|
|
350
|
+
agent.call(env)
|
|
351
|
+
elsif agent.respond_to?(:rack_app)
|
|
352
|
+
agent.rack_app.call(env)
|
|
353
|
+
else
|
|
354
|
+
body = { agent: matched_route, status: 'registered' }.to_json
|
|
355
|
+
['200', { 'Content-Type' => 'application/json' }, [body]]
|
|
356
|
+
end
|
|
357
|
+
else
|
|
358
|
+
body = { error: 'Not found', path: path }.to_json
|
|
359
|
+
['404', { 'Content-Type' => 'application/json' }, [body]]
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# @api private
|
|
367
|
+
# Attempt to serve a static file. Returns a Rack response or nil.
|
|
368
|
+
def _try_serve_static(path, static_routes)
|
|
369
|
+
matched_route = nil
|
|
370
|
+
matched_dir = nil
|
|
371
|
+
|
|
372
|
+
static_routes.each do |route, directory|
|
|
373
|
+
if path == route || path.start_with?("#{route}/")
|
|
374
|
+
if matched_route.nil? || route.length > matched_route.length
|
|
375
|
+
matched_route = route
|
|
376
|
+
matched_dir = directory
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
return nil unless matched_dir
|
|
382
|
+
|
|
383
|
+
# Extract the relative path after the route prefix
|
|
384
|
+
relative = path.sub(matched_route, '')
|
|
385
|
+
relative = '/index.html' if relative.empty? || relative == '/'
|
|
386
|
+
|
|
387
|
+
# Path traversal protection: reject any path containing ".."
|
|
388
|
+
if relative.include?('..')
|
|
389
|
+
body = JSON.generate({ error: 'Forbidden' })
|
|
390
|
+
return ['403', STATIC_SECURITY_HEADERS.merge('Content-Type' => 'application/json'), [body]]
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
file_path = File.join(matched_dir, relative)
|
|
394
|
+
resolved = File.expand_path(file_path)
|
|
395
|
+
|
|
396
|
+
# Ensure resolved path is still under the served directory
|
|
397
|
+
unless resolved.start_with?(matched_dir + '/') || resolved == matched_dir
|
|
398
|
+
body = JSON.generate({ error: 'Forbidden' })
|
|
399
|
+
return ['403', STATIC_SECURITY_HEADERS.merge('Content-Type' => 'application/json'), [body]]
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
if File.file?(resolved) && File.readable?(resolved)
|
|
403
|
+
ext = File.extname(resolved).downcase
|
|
404
|
+
content_type = MIME_TYPES[ext] || 'application/octet-stream'
|
|
405
|
+
content = File.binread(resolved)
|
|
406
|
+
headers = STATIC_SECURITY_HEADERS.merge('Content-Type' => content_type, 'Content-Length' => content.bytesize.to_s)
|
|
407
|
+
['200', headers, [content]]
|
|
408
|
+
else
|
|
409
|
+
nil
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
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 'base64'
|
|
9
|
+
require 'json'
|
|
10
|
+
require 'stringio'
|
|
11
|
+
require 'uri'
|
|
12
|
+
|
|
13
|
+
module SignalWire
|
|
14
|
+
module Serverless
|
|
15
|
+
# Adapter that lets an AWS Lambda function invoke a Rack application.
|
|
16
|
+
#
|
|
17
|
+
# Typical usage from a Lambda entrypoint file:
|
|
18
|
+
#
|
|
19
|
+
# require 'signalwire'
|
|
20
|
+
#
|
|
21
|
+
# AGENT = SignalWire::AgentBase.new(name: 'my-agent', route: '/')
|
|
22
|
+
# # ...configure AGENT...
|
|
23
|
+
#
|
|
24
|
+
# HANDLER = SignalWire::Serverless::LambdaHandler.new(AGENT.rack_app)
|
|
25
|
+
#
|
|
26
|
+
# def handler(event:, context:)
|
|
27
|
+
# HANDLER.call(event, context)
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# The adapter accepts events from either Lambda Function URLs / API
|
|
31
|
+
# Gateway HTTP API (payload format v2) or the classic API Gateway REST
|
|
32
|
+
# API (payload format v1) and returns a response in the matching
|
|
33
|
+
# shape. Any triple returned by the Rack app (status, headers, body)
|
|
34
|
+
# is translated into the +{statusCode:, headers:, body:}+ shape
|
|
35
|
+
# expected by Lambda.
|
|
36
|
+
#
|
|
37
|
+
# The adapter never reaches out to the network and has no gem
|
|
38
|
+
# dependencies beyond what the SignalWire SDK already requires, so it
|
|
39
|
+
# can be bundled directly into a Lambda zip.
|
|
40
|
+
class LambdaHandler
|
|
41
|
+
# @param app [#call] a Rack-compatible application
|
|
42
|
+
def initialize(app)
|
|
43
|
+
raise ArgumentError, 'app must respond to #call' unless app.respond_to?(:call)
|
|
44
|
+
|
|
45
|
+
@app = app
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Invoke the wrapped Rack application with a Lambda event.
|
|
49
|
+
#
|
|
50
|
+
# @param event [Hash] the Lambda invocation event
|
|
51
|
+
# @param _context [Object] the Lambda context (ignored)
|
|
52
|
+
# @return [Hash] a Lambda-shaped response hash
|
|
53
|
+
def call(event, _context = nil)
|
|
54
|
+
event ||= {}
|
|
55
|
+
env = build_env(event)
|
|
56
|
+
|
|
57
|
+
status, headers, body = @app.call(env)
|
|
58
|
+
|
|
59
|
+
build_response(event, status, headers, body)
|
|
60
|
+
ensure
|
|
61
|
+
body.close if body.respond_to?(:close)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Class-level convenience so consumers can use
|
|
65
|
+
# +SignalWire::Serverless::LambdaHandler.for(agent)+ without
|
|
66
|
+
# duplicating +.rack_app+ at the call site.
|
|
67
|
+
#
|
|
68
|
+
# @param agent_or_app [Object] either an AgentBase (responds to
|
|
69
|
+
# +rack_app+) or any Rack-compatible application
|
|
70
|
+
# @return [LambdaHandler]
|
|
71
|
+
def self.for(agent_or_app)
|
|
72
|
+
app = if agent_or_app.respond_to?(:rack_app)
|
|
73
|
+
agent_or_app.rack_app
|
|
74
|
+
else
|
|
75
|
+
agent_or_app
|
|
76
|
+
end
|
|
77
|
+
new(app)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Request: Lambda event -> Rack env
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def build_env(event)
|
|
87
|
+
version = detect_version(event)
|
|
88
|
+
method = extract_method(event, version)
|
|
89
|
+
path = extract_path(event, version)
|
|
90
|
+
query = extract_query_string(event, version)
|
|
91
|
+
headers = extract_headers(event)
|
|
92
|
+
body_io, content_length = extract_body(event)
|
|
93
|
+
|
|
94
|
+
env = {
|
|
95
|
+
'REQUEST_METHOD' => method,
|
|
96
|
+
'SCRIPT_NAME' => '',
|
|
97
|
+
'PATH_INFO' => path,
|
|
98
|
+
'QUERY_STRING' => query,
|
|
99
|
+
'SERVER_NAME' => headers['host'] || 'lambda',
|
|
100
|
+
'SERVER_PORT' => (headers['x-forwarded-port'] || '443'),
|
|
101
|
+
'SERVER_PROTOCOL' => 'HTTP/1.1',
|
|
102
|
+
'HTTP_VERSION' => 'HTTP/1.1',
|
|
103
|
+
'rack.version' => [1, 6],
|
|
104
|
+
'rack.url_scheme' => headers['x-forwarded-proto'] || 'https',
|
|
105
|
+
'rack.input' => body_io,
|
|
106
|
+
'rack.errors' => $stderr,
|
|
107
|
+
'rack.multithread' => false,
|
|
108
|
+
'rack.multiprocess' => false,
|
|
109
|
+
'rack.run_once' => true,
|
|
110
|
+
'rack.hijack?' => false,
|
|
111
|
+
'signalwire.lambda_event' => event
|
|
112
|
+
}
|
|
113
|
+
env['CONTENT_LENGTH'] = content_length.to_s if content_length
|
|
114
|
+
env['CONTENT_TYPE'] = headers['content-type'] if headers['content-type']
|
|
115
|
+
|
|
116
|
+
headers.each do |name, value|
|
|
117
|
+
next if name == 'content-type' || name == 'content-length'
|
|
118
|
+
|
|
119
|
+
env["HTTP_#{name.tr('-', '_').upcase}"] = value
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
env
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def detect_version(event)
|
|
126
|
+
# API Gateway HTTP API / Function URLs use payload v2 and include
|
|
127
|
+
# a 'version' key of "2.0". Everything else (REST API, ALB, direct
|
|
128
|
+
# invoke) is treated as v1.
|
|
129
|
+
event['version'].to_s.start_with?('2') ? 2 : 1
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def extract_method(event, version)
|
|
133
|
+
if version == 2
|
|
134
|
+
event.dig('requestContext', 'http', 'method') || event['httpMethod'] || 'GET'
|
|
135
|
+
else
|
|
136
|
+
event['httpMethod'] || 'GET'
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def extract_path(event, _version)
|
|
141
|
+
raw =
|
|
142
|
+
event['rawPath'] ||
|
|
143
|
+
event['path'] ||
|
|
144
|
+
event.dig('requestContext', 'http', 'path') ||
|
|
145
|
+
'/'
|
|
146
|
+
raw = "/#{raw}" unless raw.start_with?('/')
|
|
147
|
+
raw
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def extract_query_string(event, _version)
|
|
151
|
+
if event['rawQueryString'] && !event['rawQueryString'].empty?
|
|
152
|
+
return event['rawQueryString']
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
params = event['multiValueQueryStringParameters'] || event['queryStringParameters']
|
|
156
|
+
return '' if params.nil? || params.empty?
|
|
157
|
+
|
|
158
|
+
pairs = []
|
|
159
|
+
params.each do |k, v|
|
|
160
|
+
if v.is_a?(Array)
|
|
161
|
+
v.each { |vv| pairs << [k, vv] }
|
|
162
|
+
else
|
|
163
|
+
pairs << [k, v]
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
URI.encode_www_form(pairs)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def extract_headers(event)
|
|
170
|
+
raw = event['headers'] || {}
|
|
171
|
+
multi = event['multiValueHeaders'] || {}
|
|
172
|
+
|
|
173
|
+
merged = {}
|
|
174
|
+
raw.each { |k, v| merged[k.downcase] = v }
|
|
175
|
+
multi.each do |k, values|
|
|
176
|
+
key = k.downcase
|
|
177
|
+
merged[key] = Array(values).join(',') unless merged.key?(key)
|
|
178
|
+
end
|
|
179
|
+
merged
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def extract_body(event)
|
|
183
|
+
body = event['body']
|
|
184
|
+
return [StringIO.new(''.b), nil] if body.nil? || body.empty?
|
|
185
|
+
|
|
186
|
+
decoded = event['isBase64Encoded'] ? Base64.decode64(body) : body.dup
|
|
187
|
+
decoded = decoded.b
|
|
188
|
+
[StringIO.new(decoded), decoded.bytesize]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
# Response: Rack triple -> Lambda response hash
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def build_response(event, status, headers, body)
|
|
196
|
+
payload = collect_body(body)
|
|
197
|
+
encoded, is_base64 = maybe_base64(payload, headers)
|
|
198
|
+
|
|
199
|
+
flat_headers = {}
|
|
200
|
+
multi_headers = {}
|
|
201
|
+
(headers || {}).each do |name, value|
|
|
202
|
+
key = name.to_s
|
|
203
|
+
if value.is_a?(Array)
|
|
204
|
+
flat_headers[key] = value.join(',')
|
|
205
|
+
multi_headers[key] = value
|
|
206
|
+
else
|
|
207
|
+
flat_headers[key] = value.to_s
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
if detect_version(event) == 2
|
|
212
|
+
response = {
|
|
213
|
+
'statusCode' => status.to_i,
|
|
214
|
+
'headers' => flat_headers,
|
|
215
|
+
'body' => encoded,
|
|
216
|
+
'isBase64Encoded' => is_base64
|
|
217
|
+
}
|
|
218
|
+
response['cookies'] = multi_headers['set-cookie'] if multi_headers.key?('set-cookie')
|
|
219
|
+
response
|
|
220
|
+
else
|
|
221
|
+
{
|
|
222
|
+
'statusCode' => status.to_i,
|
|
223
|
+
'headers' => flat_headers,
|
|
224
|
+
'multiValueHeaders' => multi_headers,
|
|
225
|
+
'body' => encoded,
|
|
226
|
+
'isBase64Encoded' => is_base64
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def collect_body(body)
|
|
232
|
+
return ''.b if body.nil?
|
|
233
|
+
|
|
234
|
+
parts = []
|
|
235
|
+
body.each { |chunk| parts << chunk.to_s }
|
|
236
|
+
parts.join.b
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Lambda requires the 'body' field to be a UTF-8 string; binary
|
|
240
|
+
# responses have to be base64 encoded and flagged as such. Any byte
|
|
241
|
+
# that isn't valid UTF-8 forces base64 encoding.
|
|
242
|
+
def maybe_base64(payload, _headers)
|
|
243
|
+
if payload.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
244
|
+
[payload.to_s, false]
|
|
245
|
+
else
|
|
246
|
+
[Base64.strict_encode64(payload), true]
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|