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,79 @@
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_relative '../swaig/function_result'
9
+
10
+ module SignalWire
11
+ module Prefabs
12
+ # Prefab agent for collecting answers to a series of questions.
13
+ #
14
+ # agent = InfoGatherer.new(
15
+ # questions: [
16
+ # { 'key_name' => 'full_name', 'question_text' => 'What is your full name?' },
17
+ # { 'key_name' => 'email', 'question_text' => 'Email?', 'confirm' => true }
18
+ # ]
19
+ # )
20
+ #
21
+ class InfoGatherer
22
+ attr_reader :questions, :name, :route
23
+
24
+ def initialize(questions:, name: 'info_gatherer', route: '/info_gatherer', **_opts)
25
+ raise ArgumentError, 'questions must be a non-empty Array' unless questions.is_a?(Array) && !questions.empty?
26
+ questions.each_with_index do |q, i|
27
+ raise ArgumentError, "Question #{i} missing key_name" unless q['key_name'] || q[:key_name]
28
+ raise ArgumentError, "Question #{i} missing question_text" unless q['question_text'] || q[:question_text]
29
+ end
30
+
31
+ @questions = questions.map { |q| q.transform_keys(&:to_s) }
32
+ @name = name
33
+ @route = route
34
+ end
35
+
36
+ # Tool definitions this prefab provides.
37
+ def tools
38
+ %w[start_questions submit_answer]
39
+ end
40
+
41
+ # Build the prompt sections.
42
+ def prompt_sections
43
+ [
44
+ {
45
+ 'title' => 'Info Gatherer',
46
+ 'body' => 'You need to gather answers to a series of questions. ' \
47
+ 'Call start_questions to get the first question, then submit_answer after each response.'
48
+ }
49
+ ]
50
+ end
51
+
52
+ # Global data for initial state.
53
+ def global_data
54
+ {
55
+ 'info_gatherer' => {
56
+ 'questions' => @questions,
57
+ 'question_index' => 0,
58
+ 'answers' => []
59
+ }
60
+ }
61
+ end
62
+
63
+ # Tool handler: start_questions
64
+ def handle_start(_args, _raw_data)
65
+ q = @questions.first
66
+ Swaig::FunctionResult.new(
67
+ "[Question 1 of #{@questions.size}]: \"#{q['question_text']}\""
68
+ )
69
+ end
70
+
71
+ # Tool handler: submit_answer
72
+ def handle_submit(args, _raw_data)
73
+ answer = args['answer'] || ''
74
+ # In a real implementation, state would be tracked via global_data.
75
+ Swaig::FunctionResult.new("Answer recorded: #{answer}")
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,74 @@
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_relative '../swaig/function_result'
9
+
10
+ module SignalWire
11
+ module Prefabs
12
+ # Prefab agent for greeting callers and transferring them to departments.
13
+ #
14
+ # agent = Receptionist.new(
15
+ # departments: [
16
+ # { 'name' => 'sales', 'description' => 'Product inquiries', 'number' => '+15551235555' },
17
+ # { 'name' => 'support', 'description' => 'Technical help', 'number' => '+15551236666' }
18
+ # ]
19
+ # )
20
+ #
21
+ class Receptionist
22
+ attr_reader :departments, :name, :route, :greeting
23
+
24
+ def initialize(departments:, name: 'receptionist', route: '/receptionist',
25
+ greeting: 'Thank you for calling. How can I help you today?', **_opts)
26
+ raise ArgumentError, 'departments must be a non-empty Array' unless departments.is_a?(Array) && !departments.empty?
27
+ departments.each_with_index do |d, i|
28
+ d = d.transform_keys(&:to_s)
29
+ raise ArgumentError, "Department #{i} missing 'name'" unless d['name']
30
+ raise ArgumentError, "Department #{i} missing 'number'" unless d['number']
31
+ end
32
+
33
+ @departments = departments.map { |d| d.transform_keys(&:to_s) }
34
+ @greeting = greeting
35
+ @name = name
36
+ @route = route
37
+ end
38
+
39
+ def tools
40
+ %w[transfer_to_department collect_caller_info]
41
+ end
42
+
43
+ def prompt_sections
44
+ bullets = @departments.map { |d| "#{d['name']}: #{d['description'] || d['name']} (#{d['number']})" }
45
+ [
46
+ {
47
+ 'title' => 'Receptionist',
48
+ 'body' => @greeting,
49
+ 'bullets' => bullets
50
+ }
51
+ ]
52
+ end
53
+
54
+ def global_data
55
+ {
56
+ 'departments' => @departments,
57
+ 'caller_info' => {}
58
+ }
59
+ end
60
+
61
+ def handle_transfer(args, _raw_data)
62
+ dept_name = args['department']
63
+ dept = @departments.find { |d| d['name'] == dept_name }
64
+ if dept
65
+ result = Swaig::FunctionResult.new("Transferring you to #{dept_name} now.")
66
+ result.connect(dept['number'])
67
+ result
68
+ else
69
+ Swaig::FunctionResult.new("I couldn't find that department. Available departments: #{@departments.map { |d| d['name'] }.join(', ')}")
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,75 @@
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_relative '../swaig/function_result'
9
+
10
+ module SignalWire
11
+ module Prefabs
12
+ # Prefab agent for conducting automated surveys.
13
+ #
14
+ # agent = Survey.new(
15
+ # survey_name: 'Customer Satisfaction',
16
+ # questions: [
17
+ # { 'id' => 'satisfaction', 'text' => 'How satisfied were you?', 'type' => 'rating', 'scale' => 5 }
18
+ # ]
19
+ # )
20
+ #
21
+ class Survey
22
+ attr_reader :survey_name, :questions, :name, :route
23
+
24
+ def initialize(survey_name:, questions:, introduction: nil, conclusion: nil,
25
+ name: 'survey', route: '/survey', **_opts)
26
+ raise ArgumentError, 'questions must be a non-empty Array' unless questions.is_a?(Array) && !questions.empty?
27
+
28
+ @survey_name = survey_name
29
+ @questions = questions.map { |q| q.transform_keys(&:to_s) }
30
+ @introduction = introduction || "Welcome to the #{survey_name}. Let's get started."
31
+ @conclusion = conclusion || 'Thank you for completing the survey!'
32
+ @name = name
33
+ @route = route
34
+ end
35
+
36
+ def tools
37
+ %w[start_survey submit_survey_answer get_survey_summary]
38
+ end
39
+
40
+ def prompt_sections
41
+ [
42
+ {
43
+ 'title' => "Survey: #{@survey_name}",
44
+ 'body' => @introduction,
45
+ 'bullets' => @questions.map { |q| "#{q['id']}: #{q['text']} (#{q['type'] || 'open_ended'})" }
46
+ }
47
+ ]
48
+ end
49
+
50
+ def global_data
51
+ {
52
+ 'survey' => {
53
+ 'name' => @survey_name,
54
+ 'questions' => @questions,
55
+ 'current' => 0,
56
+ 'responses' => {}
57
+ }
58
+ }
59
+ end
60
+
61
+ def handle_start(_args, _raw_data)
62
+ q = @questions.first
63
+ Swaig::FunctionResult.new("#{@introduction}\n\n[Question 1 of #{@questions.size}]: #{q['text']}")
64
+ end
65
+
66
+ def handle_submit(args, _raw_data)
67
+ Swaig::FunctionResult.new("Response recorded: #{args['answer']}")
68
+ end
69
+
70
+ def handle_summary(_args, _raw_data)
71
+ Swaig::FunctionResult.new(@conclusion)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module SignalWire
6
+ module Relay
7
+ # Raised when an action times out waiting for completion.
8
+ class ActionTimeoutError < StandardError; end
9
+
10
+ # Base class for async action handles (play, record, detect, etc.).
11
+ #
12
+ # Holds a control_id and back-reference to the Call. Resolves when the
13
+ # server sends a terminal event for this control_id.
14
+ #
15
+ # Uses Ruby's Queue for blocking wait semantics.
16
+ class Action
17
+ attr_reader :control_id, :call, :result, :completed
18
+
19
+ def initialize(call, control_id, terminal_event, terminal_states)
20
+ @call = call
21
+ @control_id = control_id
22
+ @terminal_event = terminal_event
23
+ @terminal_states = terminal_states
24
+ @result = nil
25
+ @completed = false
26
+ @mutex = Mutex.new
27
+ @condition = ConditionVariable.new
28
+ @on_completed = nil
29
+ end
30
+
31
+ # Set the on_completed callback.
32
+ def on_completed(&block)
33
+ @on_completed = block
34
+ end
35
+
36
+ # Called internally to set the on_completed callback from options.
37
+ def _set_on_completed(callback)
38
+ @on_completed = callback
39
+ end
40
+
41
+ # Called by Call when an event matches our control_id.
42
+ def _check_event(event)
43
+ state = event.params['state'] || ''
44
+ if @terminal_states.include?(state) && !@completed
45
+ _resolve(event)
46
+ end
47
+ end
48
+
49
+ # Mark the action as completed and fire the on_completed callback.
50
+ def _resolve(event)
51
+ @mutex.synchronize do
52
+ return if @completed
53
+
54
+ @result = event
55
+ @completed = true
56
+ @condition.broadcast
57
+ end
58
+ if @on_completed
59
+ begin
60
+ @on_completed.call(event)
61
+ rescue => e
62
+ $stderr.puts "[RELAY] Error in on_completed callback for #{@control_id}: #{e.message}"
63
+ end
64
+ end
65
+ end
66
+
67
+ # Wait for the action to complete. Returns the terminal event.
68
+ # Raises ActionTimeoutError if timeout is specified and exceeded.
69
+ def wait(timeout: nil)
70
+ @mutex.synchronize do
71
+ return @result if @completed
72
+
73
+ if timeout
74
+ deadline = Time.now + timeout
75
+ while !@completed
76
+ remaining = deadline - Time.now
77
+ if remaining <= 0
78
+ raise ActionTimeoutError, "Action #{@control_id} timed out after #{timeout}s"
79
+ end
80
+ @condition.wait(@mutex, remaining)
81
+ end
82
+ else
83
+ @condition.wait(@mutex) until @completed
84
+ end
85
+ @result
86
+ end
87
+ end
88
+
89
+ def done?
90
+ @completed
91
+ end
92
+
93
+ alias_method :is_done?, :done?
94
+ end
95
+
96
+ # Handle for an active play operation.
97
+ class PlayAction < Action
98
+ def initialize(call, control_id)
99
+ super(call, control_id, EVENT_CALL_PLAY,
100
+ [PLAY_STATE_FINISHED, PLAY_STATE_ERROR])
101
+ end
102
+
103
+ def stop
104
+ @call._execute('play.stop', { 'control_id' => @control_id })
105
+ end
106
+
107
+ def pause
108
+ @call._execute('play.pause', { 'control_id' => @control_id })
109
+ end
110
+
111
+ def resume
112
+ @call._execute('play.resume', { 'control_id' => @control_id })
113
+ end
114
+
115
+ def volume(vol)
116
+ @call._execute('play.volume', { 'control_id' => @control_id, 'volume' => vol })
117
+ end
118
+ end
119
+
120
+ # Handle for an active record operation.
121
+ class RecordAction < Action
122
+ def initialize(call, control_id)
123
+ super(call, control_id, EVENT_CALL_RECORD,
124
+ [RECORD_STATE_FINISHED, RECORD_STATE_NO_INPUT])
125
+ end
126
+
127
+ def stop
128
+ @call._execute('record.stop', { 'control_id' => @control_id })
129
+ end
130
+
131
+ def pause(behavior: nil)
132
+ params = { 'control_id' => @control_id }
133
+ params['behavior'] = behavior if behavior
134
+ @call._execute('record.pause', params)
135
+ end
136
+
137
+ def resume
138
+ @call._execute('record.resume', { 'control_id' => @control_id })
139
+ end
140
+ end
141
+
142
+ # Handle for an active detect operation.
143
+ class DetectAction < Action
144
+ def initialize(call, control_id)
145
+ super(call, control_id, EVENT_CALL_DETECT, %w[finished error])
146
+ end
147
+
148
+ # Detect delivers results continuously. Resolve on first result or
149
+ # when finished/error.
150
+ def _check_event(event)
151
+ detect = event.params['detect'] || {}
152
+ state = event.params['state'] || ''
153
+ if (!detect.empty? || @terminal_states.include?(state)) && !@completed
154
+ _resolve(event)
155
+ end
156
+ end
157
+
158
+ def stop
159
+ @call._execute('detect.stop', { 'control_id' => @control_id })
160
+ end
161
+ end
162
+
163
+ # Handle for play_and_collect or standalone collect.
164
+ class CollectAction < Action
165
+ def initialize(call, control_id)
166
+ super(call, control_id, EVENT_CALL_COLLECT,
167
+ %w[finished error no_input no_match])
168
+ end
169
+
170
+ # play_and_collect shares a control_id across play and collect
171
+ # phases. Only resolve on collect events, not play events.
172
+ def _check_event(event)
173
+ return unless event.event_type == EVENT_CALL_COLLECT
174
+
175
+ result_data = event.params['result'] || {}
176
+ if !result_data.empty? && !@completed
177
+ _resolve(event)
178
+ else
179
+ super(event)
180
+ end
181
+ end
182
+
183
+ def stop
184
+ @call._execute('play_and_collect.stop', { 'control_id' => @control_id })
185
+ end
186
+
187
+ def volume(vol)
188
+ @call._execute('play_and_collect.volume', {
189
+ 'control_id' => @control_id, 'volume' => vol
190
+ })
191
+ end
192
+
193
+ def start_input_timers
194
+ @call._execute('collect.start_input_timers', { 'control_id' => @control_id })
195
+ end
196
+ end
197
+
198
+ # Handle for standalone calling.collect (without play).
199
+ class StandaloneCollectAction < Action
200
+ def initialize(call, control_id)
201
+ super(call, control_id, EVENT_CALL_COLLECT,
202
+ %w[finished error no_input no_match])
203
+ end
204
+
205
+ def _check_event(event)
206
+ return unless event.event_type == EVENT_CALL_COLLECT
207
+
208
+ result_data = event.params['result'] || {}
209
+ state = event.params['state'] || ''
210
+ if (!result_data.empty? || @terminal_states.include?(state)) && !@completed
211
+ _resolve(event)
212
+ end
213
+ end
214
+
215
+ def stop
216
+ @call._execute('collect.stop', { 'control_id' => @control_id })
217
+ end
218
+
219
+ def start_input_timers
220
+ @call._execute('collect.start_input_timers', { 'control_id' => @control_id })
221
+ end
222
+ end
223
+
224
+ # Handle for send_fax or receive_fax.
225
+ class FaxAction < Action
226
+ def initialize(call, control_id, method_prefix)
227
+ super(call, control_id, EVENT_CALL_FAX, %w[finished error])
228
+ @method_prefix = method_prefix
229
+ end
230
+
231
+ def stop
232
+ @call._execute("#{@method_prefix}.stop", { 'control_id' => @control_id })
233
+ end
234
+ end
235
+
236
+ # Handle for an active tap operation.
237
+ class TapAction < Action
238
+ def initialize(call, control_id)
239
+ super(call, control_id, EVENT_CALL_TAP, %w[finished])
240
+ end
241
+
242
+ def stop
243
+ @call._execute('tap.stop', { 'control_id' => @control_id })
244
+ end
245
+ end
246
+
247
+ # Handle for an active stream operation.
248
+ class StreamAction < Action
249
+ def initialize(call, control_id)
250
+ super(call, control_id, EVENT_CALL_STREAM, %w[finished])
251
+ end
252
+
253
+ def stop
254
+ @call._execute('stream.stop', { 'control_id' => @control_id })
255
+ end
256
+ end
257
+
258
+ # Handle for an active pay operation.
259
+ class PayAction < Action
260
+ def initialize(call, control_id)
261
+ super(call, control_id, EVENT_CALL_PAY, %w[finished error])
262
+ end
263
+
264
+ def stop
265
+ @call._execute('pay.stop', { 'control_id' => @control_id })
266
+ end
267
+ end
268
+
269
+ # Handle for an active transcribe operation.
270
+ class TranscribeAction < Action
271
+ def initialize(call, control_id)
272
+ super(call, control_id, EVENT_CALL_TRANSCRIBE, %w[finished])
273
+ end
274
+
275
+ def stop
276
+ @call._execute('transcribe.stop', { 'control_id' => @control_id })
277
+ end
278
+ end
279
+
280
+ # Handle for an active AI agent session.
281
+ class AIAction < Action
282
+ def initialize(call, control_id)
283
+ super(call, control_id, 'calling.call.ai', %w[finished error])
284
+ end
285
+
286
+ def stop
287
+ @call._execute('ai.stop', { 'control_id' => @control_id })
288
+ end
289
+ end
290
+ end
291
+ end