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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +259 -0
  4. data/bin/swaig-test +872 -0
  5. data/lib/signalwire/agent/agent_base.rb +2134 -0
  6. data/lib/signalwire/contexts/context_builder.rb +861 -0
  7. data/lib/signalwire/core/logging_config.rb +54 -0
  8. data/lib/signalwire/datamap/data_map.rb +315 -0
  9. data/lib/signalwire/logging.rb +92 -0
  10. data/lib/signalwire/pom/prompt_object_model.rb +269 -0
  11. data/lib/signalwire/pom/section.rb +202 -0
  12. data/lib/signalwire/prefabs/concierge.rb +92 -0
  13. data/lib/signalwire/prefabs/faq_bot.rb +67 -0
  14. data/lib/signalwire/prefabs/info_gatherer.rb +79 -0
  15. data/lib/signalwire/prefabs/receptionist.rb +74 -0
  16. data/lib/signalwire/prefabs/survey.rb +75 -0
  17. data/lib/signalwire/relay/action.rb +291 -0
  18. data/lib/signalwire/relay/call.rb +523 -0
  19. data/lib/signalwire/relay/client.rb +789 -0
  20. data/lib/signalwire/relay/constants.rb +124 -0
  21. data/lib/signalwire/relay/message.rb +137 -0
  22. data/lib/signalwire/relay/relay_event.rb +670 -0
  23. data/lib/signalwire/rest/http_client.rb +159 -0
  24. data/lib/signalwire/rest/namespaces/addresses.rb +19 -0
  25. data/lib/signalwire/rest/namespaces/calling.rb +179 -0
  26. data/lib/signalwire/rest/namespaces/chat.rb +18 -0
  27. data/lib/signalwire/rest/namespaces/compat.rb +229 -0
  28. data/lib/signalwire/rest/namespaces/datasphere.rb +39 -0
  29. data/lib/signalwire/rest/namespaces/fabric.rb +235 -0
  30. data/lib/signalwire/rest/namespaces/imported_numbers.rb +18 -0
  31. data/lib/signalwire/rest/namespaces/logs.rb +46 -0
  32. data/lib/signalwire/rest/namespaces/lookup.rb +18 -0
  33. data/lib/signalwire/rest/namespaces/mfa.rb +26 -0
  34. data/lib/signalwire/rest/namespaces/number_groups.rb +32 -0
  35. data/lib/signalwire/rest/namespaces/phone_numbers.rb +124 -0
  36. data/lib/signalwire/rest/namespaces/project.rb +33 -0
  37. data/lib/signalwire/rest/namespaces/pubsub.rb +18 -0
  38. data/lib/signalwire/rest/namespaces/queues.rb +28 -0
  39. data/lib/signalwire/rest/namespaces/recordings.rb +18 -0
  40. data/lib/signalwire/rest/namespaces/registry.rb +67 -0
  41. data/lib/signalwire/rest/namespaces/short_codes.rb +26 -0
  42. data/lib/signalwire/rest/namespaces/sip_profile.rb +22 -0
  43. data/lib/signalwire/rest/namespaces/verified_callers.rb +24 -0
  44. data/lib/signalwire/rest/namespaces/video.rb +129 -0
  45. data/lib/signalwire/rest/pagination.rb +89 -0
  46. data/lib/signalwire/rest/phone_call_handler.rb +56 -0
  47. data/lib/signalwire/rest/rest_client.rb +114 -0
  48. data/lib/signalwire/runtime.rb +98 -0
  49. data/lib/signalwire/security/session_manager.rb +124 -0
  50. data/lib/signalwire/security/webhook_middleware.rb +191 -0
  51. data/lib/signalwire/security/webhook_validator.rb +327 -0
  52. data/lib/signalwire/server/agent_server.rb +413 -0
  53. data/lib/signalwire/serverless/lambda_handler.rb +251 -0
  54. data/lib/signalwire/skills/builtin/api_ninjas_trivia.rb +99 -0
  55. data/lib/signalwire/skills/builtin/claude_skills.rb +92 -0
  56. data/lib/signalwire/skills/builtin/custom_skills.rb +54 -0
  57. data/lib/signalwire/skills/builtin/datasphere.rb +153 -0
  58. data/lib/signalwire/skills/builtin/datasphere_serverless.rb +107 -0
  59. data/lib/signalwire/skills/builtin/datetime.rb +97 -0
  60. data/lib/signalwire/skills/builtin/google_maps.rb +168 -0
  61. data/lib/signalwire/skills/builtin/info_gatherer.rb +189 -0
  62. data/lib/signalwire/skills/builtin/joke.rb +65 -0
  63. data/lib/signalwire/skills/builtin/math.rb +176 -0
  64. data/lib/signalwire/skills/builtin/mcp_gateway.rb +121 -0
  65. data/lib/signalwire/skills/builtin/native_vector_search.rb +116 -0
  66. data/lib/signalwire/skills/builtin/play_background_file.rb +86 -0
  67. data/lib/signalwire/skills/builtin/spider.rb +169 -0
  68. data/lib/signalwire/skills/builtin/swml_transfer.rb +118 -0
  69. data/lib/signalwire/skills/builtin/weather_api.rb +92 -0
  70. data/lib/signalwire/skills/builtin/web_search.rb +141 -0
  71. data/lib/signalwire/skills/builtin/wikipedia_search.rb +125 -0
  72. data/lib/signalwire/skills/skill_base.rb +82 -0
  73. data/lib/signalwire/skills/skill_manager.rb +97 -0
  74. data/lib/signalwire/skills/skill_registry.rb +258 -0
  75. data/lib/signalwire/swaig/function_result.rb +777 -0
  76. data/lib/signalwire/swml/document.rb +84 -0
  77. data/lib/signalwire/swml/schema.json +12250 -0
  78. data/lib/signalwire/swml/schema.rb +81 -0
  79. data/lib/signalwire/swml/service.rb +650 -0
  80. data/lib/signalwire/utils/schema_utils.rb +298 -0
  81. data/lib/signalwire/utils/serverless.rb +19 -0
  82. data/lib/signalwire/utils/url_validator.rb +138 -0
  83. data/lib/signalwire/version.rb +5 -0
  84. data/lib/signalwire.rb +114 -0
  85. 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