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,777 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2025 SignalWire
4
+ #
5
+ # Licensed under the MIT License.
6
+ # See LICENSE file in the project root for full license information.
7
+
8
+ require 'json'
9
+
10
+ module SignalWire
11
+ module Swaig
12
+ # Response builder that tool handlers return.
13
+ # All mutating methods return +self+ for fluent chaining.
14
+ #
15
+ # result = FunctionResult.new("Found your order")
16
+ # .update_global_data("order_id" => "12345")
17
+ # .say("Let me look that up")
18
+ #
19
+ # The result object has three main components:
20
+ # 1. response - Text the AI should say back to the user
21
+ # 2. action - List of structured actions to execute
22
+ # 3. post_process - Whether to let AI take another turn before executing actions
23
+ #
24
+ class FunctionResult
25
+ attr_accessor :response, :action, :post_process
26
+
27
+ # @param response [String, nil] text the AI speaks back to the user
28
+ # @param post_process [Boolean] whether to let AI take another turn before executing actions
29
+ def initialize(response = nil, post_process: false)
30
+ @response = response || ""
31
+ @action = []
32
+ @post_process = post_process
33
+ end
34
+
35
+ # ------------------------------------------------------------------
36
+ # Core mutators
37
+ # ------------------------------------------------------------------
38
+
39
+ # Set the natural-language response text.
40
+ # @return [self]
41
+ def set_response(text)
42
+ @response = text
43
+ self
44
+ end
45
+
46
+ # Enable / disable post-processing.
47
+ # @return [self]
48
+ def set_post_process(val)
49
+ @post_process = val
50
+ self
51
+ end
52
+
53
+ # Add a single structured action.
54
+ # @param name [String] action key
55
+ # @param data [Object] action value
56
+ # @return [self]
57
+ def add_action(name, data)
58
+ @action << { name => data }
59
+ self
60
+ end
61
+
62
+ # Add multiple structured actions at once.
63
+ # @param actions [Array<Hash>]
64
+ # @return [self]
65
+ def add_actions(actions)
66
+ @action.concat(actions)
67
+ self
68
+ end
69
+
70
+ # ==================================================================
71
+ # Call Control
72
+ # ==================================================================
73
+
74
+ # Connect / transfer the call to another destination.
75
+ #
76
+ # @param destination [String] phone number, SIP address, etc.
77
+ # @param final [Boolean] permanent (+true+) or temporary (+false+) transfer
78
+ # @param from_addr [String, nil] optional caller-ID override
79
+ # @return [self]
80
+ def connect(destination, final: true, from_addr: nil)
81
+ connect_params = { "to" => destination }
82
+ connect_params["from"] = from_addr if from_addr
83
+
84
+ swml_action = {
85
+ "SWML" => {
86
+ "sections" => {
87
+ "main" => [{ "connect" => connect_params }]
88
+ },
89
+ "version" => "1.0.0"
90
+ },
91
+ "transfer" => final.to_s
92
+ }
93
+
94
+ @action << swml_action
95
+ self
96
+ end
97
+
98
+ # Transfer via SWML with an AI response when transfer completes.
99
+ #
100
+ # @param dest [String] destination URL for the transfer
101
+ # @param ai_response [String] message AI says when transfer completes
102
+ # @param final [Boolean] permanent or temporary transfer
103
+ # @return [self]
104
+ def swml_transfer(dest, ai_response, final: true)
105
+ swml_action = {
106
+ "SWML" => {
107
+ "version" => "1.0.0",
108
+ "sections" => {
109
+ "main" => [
110
+ { "set" => { "ai_response" => ai_response } },
111
+ { "transfer" => { "dest" => dest } }
112
+ ]
113
+ }
114
+ },
115
+ "transfer" => final.to_s
116
+ }
117
+
118
+ @action << swml_action
119
+ self
120
+ end
121
+
122
+ # Terminate the call.
123
+ # @return [self]
124
+ def hangup
125
+ add_action("hangup", true)
126
+ end
127
+
128
+ # Put the call on hold.
129
+ # @param timeout [Integer] seconds, clamped to 0..900
130
+ # @return [self]
131
+ def hold(timeout = 300)
132
+ timeout = [[timeout, 0].max, 900].min
133
+ add_action("hold", timeout)
134
+ end
135
+
136
+ # Control how the agent waits for user input.
137
+ #
138
+ # @param enabled [Boolean, nil] enable/disable waiting
139
+ # @param timeout [Integer, nil] seconds to wait
140
+ # @param answer_first [Boolean] special "answer_first" mode
141
+ # @return [self]
142
+ def wait_for_user(enabled: nil, timeout: nil, answer_first: false)
143
+ wait_value = if answer_first
144
+ "answer_first"
145
+ elsif timeout
146
+ timeout
147
+ elsif !enabled.nil?
148
+ enabled
149
+ else
150
+ true
151
+ end
152
+ add_action("wait_for_user", wait_value)
153
+ end
154
+
155
+ # Stop agent execution.
156
+ # @return [self]
157
+ def stop
158
+ add_action("stop", true)
159
+ end
160
+
161
+ # ==================================================================
162
+ # State & Data Management
163
+ # ==================================================================
164
+
165
+ # Update global agent data variables.
166
+ # @param data [Hash] key-value pairs to set/update
167
+ # @return [self]
168
+ def update_global_data(data)
169
+ add_action("set_global_data", data)
170
+ end
171
+
172
+ # Remove global agent data variables.
173
+ # @param keys [String, Array<String>] key(s) to remove
174
+ # @return [self]
175
+ def remove_global_data(keys)
176
+ add_action("unset_global_data", keys)
177
+ end
178
+
179
+ # Set metadata scoped to current function's meta_data_token.
180
+ # @param data [Hash]
181
+ # @return [self]
182
+ def set_metadata(data)
183
+ add_action("set_meta_data", data)
184
+ end
185
+
186
+ # Remove metadata from current function's scope.
187
+ # @param keys [String, Array<String>]
188
+ # @return [self]
189
+ def remove_metadata(keys)
190
+ add_action("unset_meta_data", keys)
191
+ end
192
+
193
+ # Send a user event through SWML.
194
+ # @param event_data [Hash] event payload
195
+ # @return [self]
196
+ def swml_user_event(event_data)
197
+ swml_action = {
198
+ "sections" => {
199
+ "main" => [{
200
+ "user_event" => { "event" => event_data }
201
+ }]
202
+ },
203
+ "version" => "1.0.0"
204
+ }
205
+ add_action("SWML", swml_action)
206
+ end
207
+
208
+ # Change the conversation step.
209
+ # @param step_name [String]
210
+ # @return [self]
211
+ def swml_change_step(step_name)
212
+ add_action("change_step", step_name)
213
+ end
214
+
215
+ # Change the conversation context.
216
+ # @param context_name [String]
217
+ # @return [self]
218
+ def swml_change_context(context_name)
219
+ add_action("change_context", context_name)
220
+ end
221
+
222
+ # Switch agent context/prompt during conversation.
223
+ #
224
+ # When only +system_prompt+ is provided and all flags are false, emits
225
+ # a simple string context switch. Otherwise emits the full object form.
226
+ #
227
+ # @param system_prompt [String, nil]
228
+ # @param user_prompt [String, nil]
229
+ # @param consolidate [Boolean]
230
+ # @param full_reset [Boolean]
231
+ # @param isolated [Boolean]
232
+ # @return [self]
233
+ def switch_context(system_prompt: nil, user_prompt: nil,
234
+ consolidate: false, full_reset: false, isolated: false)
235
+ if system_prompt && !user_prompt && !consolidate && !full_reset && !isolated
236
+ return add_action("context_switch", system_prompt)
237
+ end
238
+
239
+ context_data = {}
240
+ context_data["system_prompt"] = system_prompt if system_prompt
241
+ context_data["user_prompt"] = user_prompt if user_prompt
242
+ context_data["consolidate"] = true if consolidate
243
+ context_data["full_reset"] = true if full_reset
244
+ context_data["isolated"] = true if isolated
245
+ add_action("context_switch", context_data)
246
+ end
247
+
248
+ # Replace the tool_call + result pair in conversation history.
249
+ #
250
+ # @param text [String, true] replacement text, or +true+ to remove entirely
251
+ # @return [self]
252
+ def replace_in_history(text = true)
253
+ add_action("replace_in_history", text)
254
+ end
255
+
256
+ # ==================================================================
257
+ # Media Control
258
+ # ==================================================================
259
+
260
+ # Make the agent speak specific text.
261
+ # @param text [String]
262
+ # @return [self]
263
+ def say(text)
264
+ add_action("say", text)
265
+ end
266
+
267
+ # Play audio/video file in the background.
268
+ #
269
+ # @param filename [String] audio/video filename or URL
270
+ # @param wait [Boolean] suppress attention-getting behaviour during playback
271
+ # @return [self]
272
+ def play_background_file(filename, wait: false)
273
+ if wait
274
+ add_action("playback_bg", { "file" => filename, "wait" => true })
275
+ else
276
+ add_action("playback_bg", filename)
277
+ end
278
+ end
279
+
280
+ # Stop currently playing background file.
281
+ # @return [self]
282
+ def stop_background_file
283
+ add_action("stop_playback_bg", true)
284
+ end
285
+
286
+ # Start background call recording via SWML.
287
+ #
288
+ # @param control_id [String, nil]
289
+ # @param stereo [Boolean]
290
+ # @param format [String] "wav" or "mp3"
291
+ # @param direction [String] "speak", "listen", or "both"
292
+ # @return [self]
293
+ def record_call(control_id: nil, stereo: false, format: "wav",
294
+ direction: "both", terminators: nil, beep: false,
295
+ input_sensitivity: 44.0, initial_timeout: nil,
296
+ end_silence_timeout: nil, max_length: nil, status_url: nil)
297
+ raise ArgumentError, "format must be 'wav' or 'mp3'" unless %w[wav mp3].include?(format)
298
+ raise ArgumentError, "direction must be 'speak', 'listen', or 'both'" unless %w[speak listen both].include?(direction)
299
+
300
+ record_params = {
301
+ "stereo" => stereo,
302
+ "format" => format,
303
+ "direction" => direction,
304
+ "beep" => beep,
305
+ "input_sensitivity" => input_sensitivity
306
+ }
307
+ record_params["control_id"] = control_id if control_id
308
+ record_params["terminators"] = terminators if terminators
309
+ record_params["initial_timeout"] = initial_timeout if initial_timeout
310
+ record_params["end_silence_timeout"] = end_silence_timeout if end_silence_timeout
311
+ record_params["max_length"] = max_length if max_length
312
+ record_params["status_url"] = status_url if status_url
313
+
314
+ swml_doc = {
315
+ "version" => "1.0.0",
316
+ "sections" => { "main" => [{ "record_call" => record_params }] }
317
+ }
318
+ execute_swml(swml_doc)
319
+ end
320
+
321
+ # Stop an active background call recording.
322
+ # @param control_id [String, nil]
323
+ # @return [self]
324
+ def stop_record_call(control_id: nil)
325
+ stop_params = {}
326
+ stop_params["control_id"] = control_id if control_id
327
+
328
+ swml_doc = {
329
+ "version" => "1.0.0",
330
+ "sections" => { "main" => [{ "stop_record_call" => stop_params }] }
331
+ }
332
+ execute_swml(swml_doc)
333
+ end
334
+
335
+ # ==================================================================
336
+ # Speech & AI Configuration
337
+ # ==================================================================
338
+
339
+ # Add dynamic speech recognition hints.
340
+ # @param hints [Array<String, Hash>]
341
+ # @return [self]
342
+ def add_dynamic_hints(hints)
343
+ add_action("add_dynamic_hints", hints)
344
+ end
345
+
346
+ # Clear all dynamic speech recognition hints.
347
+ # @return [self]
348
+ def clear_dynamic_hints
349
+ @action << { "clear_dynamic_hints" => {} }
350
+ self
351
+ end
352
+
353
+ # Adjust end-of-speech timeout.
354
+ # @param milliseconds [Integer]
355
+ # @return [self]
356
+ def set_end_of_speech_timeout(milliseconds)
357
+ add_action("end_of_speech_timeout", milliseconds)
358
+ end
359
+
360
+ # Adjust speech event timeout.
361
+ # @param milliseconds [Integer]
362
+ # @return [self]
363
+ def set_speech_event_timeout(milliseconds)
364
+ add_action("speech_event_timeout", milliseconds)
365
+ end
366
+
367
+ # Enable / disable specific SWAIG functions.
368
+ # @param toggles [Array<Hash>] each with "function" and "active" keys
369
+ # @return [self]
370
+ def toggle_functions(toggles)
371
+ add_action("toggle_functions", toggles)
372
+ end
373
+
374
+ # Enable function calls on speaker timeout.
375
+ # @param enabled [Boolean]
376
+ # @return [self]
377
+ def enable_functions_on_timeout(enabled = true)
378
+ add_action("functions_on_speaker_timeout", enabled)
379
+ end
380
+
381
+ # Send full data to LLM for this turn only.
382
+ # @param enabled [Boolean]
383
+ # @return [self]
384
+ def enable_extensive_data(enabled = true)
385
+ add_action("extensive_data", enabled)
386
+ end
387
+
388
+ # Update agent runtime settings (temperature, top_p, etc.).
389
+ # @param settings [Hash]
390
+ # @return [self]
391
+ def update_settings(settings)
392
+ add_action("settings", settings)
393
+ end
394
+
395
+ # ==================================================================
396
+ # Advanced Features
397
+ # ==================================================================
398
+
399
+ # Execute SWML content with optional transfer.
400
+ #
401
+ # @param swml_content [Hash, String] SWML data structure or JSON string
402
+ # @param transfer [Boolean] whether call should exit agent after execution
403
+ # @return [self]
404
+ def execute_swml(swml_content, transfer: false)
405
+ swml_data = case swml_content
406
+ when String
407
+ begin
408
+ JSON.parse(swml_content)
409
+ rescue JSON::ParserError
410
+ { "raw_swml" => swml_content }
411
+ end
412
+ when Hash
413
+ swml_content.dup
414
+ else
415
+ if swml_content.respond_to?(:to_h)
416
+ swml_content.to_h
417
+ else
418
+ raise TypeError, "swml_content must be a String, Hash, or respond to #to_h"
419
+ end
420
+ end
421
+
422
+ swml_data["transfer"] = "true" if transfer
423
+ add_action("SWML", swml_data)
424
+ end
425
+
426
+ # Join an ad-hoc audio conference via SWML.
427
+ #
428
+ # @param name [String] conference name (required)
429
+ # @return [self]
430
+ def join_conference(name, muted: false, beep: "true",
431
+ start_on_enter: true, end_on_exit: false,
432
+ wait_url: nil, max_participants: 250,
433
+ record: "do-not-record", region: nil,
434
+ trim: "trim-silence", coach: nil,
435
+ status_callback_event: nil, status_callback: nil,
436
+ status_callback_method: "POST",
437
+ recording_status_callback: nil,
438
+ recording_status_callback_method: "POST",
439
+ recording_status_callback_event: "completed",
440
+ result: nil)
441
+ raise ArgumentError, "name cannot be empty" if name.to_s.strip.empty?
442
+ raise ArgumentError, "beep must be one of: true, false, onEnter, onExit" unless %w[true false onEnter onExit].include?(beep)
443
+ raise ArgumentError, "max_participants must be 1..250" unless max_participants.between?(1, 250)
444
+ raise ArgumentError, "record must be 'do-not-record' or 'record-from-start'" unless %w[do-not-record record-from-start].include?(record)
445
+ raise ArgumentError, "trim must be 'trim-silence' or 'do-not-trim'" unless %w[trim-silence do-not-trim].include?(trim)
446
+
447
+ all_defaults = !muted && beep == "true" && start_on_enter && !end_on_exit &&
448
+ wait_url.nil? && max_participants == 250 && record == "do-not-record" &&
449
+ region.nil? && trim == "trim-silence" && coach.nil? &&
450
+ status_callback_event.nil? && status_callback.nil? &&
451
+ status_callback_method == "POST" && recording_status_callback.nil? &&
452
+ recording_status_callback_method == "POST" &&
453
+ recording_status_callback_event == "completed" && result.nil?
454
+
455
+ if all_defaults
456
+ join_params = name
457
+ else
458
+ join_params = { "name" => name }
459
+ join_params["muted"] = muted if muted
460
+ join_params["beep"] = beep if beep != "true"
461
+ join_params["start_on_enter"] = start_on_enter unless start_on_enter
462
+ join_params["end_on_exit"] = end_on_exit if end_on_exit
463
+ join_params["wait_url"] = wait_url if wait_url
464
+ join_params["max_participants"] = max_participants if max_participants != 250
465
+ join_params["record"] = record if record != "do-not-record"
466
+ join_params["region"] = region if region
467
+ join_params["trim"] = trim if trim != "trim-silence"
468
+ join_params["coach"] = coach if coach
469
+ join_params["status_callback_event"] = status_callback_event if status_callback_event
470
+ join_params["status_callback"] = status_callback if status_callback
471
+ join_params["status_callback_method"] = status_callback_method if status_callback_method != "POST"
472
+ join_params["recording_status_callback"] = recording_status_callback if recording_status_callback
473
+ join_params["recording_status_callback_method"] = recording_status_callback_method if recording_status_callback_method != "POST"
474
+ join_params["recording_status_callback_event"] = recording_status_callback_event if recording_status_callback_event != "completed"
475
+ join_params["result"] = result if result
476
+ end
477
+
478
+ swml_doc = {
479
+ "version" => "1.0.0",
480
+ "sections" => { "main" => [{ "join_conference" => join_params }] }
481
+ }
482
+ execute_swml(swml_doc)
483
+ end
484
+
485
+ # Join a RELAY room via SWML.
486
+ # @param name [String]
487
+ # @return [self]
488
+ def join_room(name)
489
+ swml_doc = {
490
+ "version" => "1.0.0",
491
+ "sections" => { "main" => [{ "join_room" => { "name" => name } }] }
492
+ }
493
+ execute_swml(swml_doc)
494
+ end
495
+
496
+ # Send SIP REFER via SWML.
497
+ # @param to_uri [String]
498
+ # @return [self]
499
+ def sip_refer(to_uri)
500
+ swml_doc = {
501
+ "version" => "1.0.0",
502
+ "sections" => { "main" => [{ "sip_refer" => { "to_uri" => to_uri } }] }
503
+ }
504
+ execute_swml(swml_doc)
505
+ end
506
+
507
+ # Start a background call tap via SWML.
508
+ #
509
+ # @param uri [String] destination URI (rtp://, ws://, wss://)
510
+ # @param control_id [String, nil]
511
+ # @param direction [String] "speak", "hear", or "both"
512
+ # @param codec [String] "PCMU" or "PCMA"
513
+ # @param rtp_ptime [Integer] packetization time in ms
514
+ # @param status_url [String, nil]
515
+ # @return [self]
516
+ def tap(uri, control_id: nil, direction: "both", codec: "PCMU",
517
+ rtp_ptime: 20, status_url: nil)
518
+ raise ArgumentError, "direction must be 'speak', 'hear', or 'both'" unless %w[speak hear both].include?(direction)
519
+ raise ArgumentError, "codec must be 'PCMU' or 'PCMA'" unless %w[PCMU PCMA].include?(codec)
520
+ raise ArgumentError, "rtp_ptime must be positive" unless rtp_ptime.positive?
521
+
522
+ tap_params = { "uri" => uri }
523
+ tap_params["control_id"] = control_id if control_id
524
+ tap_params["direction"] = direction if direction != "both"
525
+ tap_params["codec"] = codec if codec != "PCMU"
526
+ tap_params["rtp_ptime"] = rtp_ptime if rtp_ptime != 20
527
+ tap_params["status_url"] = status_url if status_url
528
+
529
+ swml_doc = {
530
+ "version" => "1.0.0",
531
+ "sections" => { "main" => [{ "tap" => tap_params }] }
532
+ }
533
+ execute_swml(swml_doc)
534
+ end
535
+
536
+ # Stop an active tap stream via SWML.
537
+ # @param control_id [String, nil]
538
+ # @return [self]
539
+ def stop_tap(control_id: nil)
540
+ stop_params = {}
541
+ stop_params["control_id"] = control_id if control_id
542
+
543
+ swml_doc = {
544
+ "version" => "1.0.0",
545
+ "sections" => { "main" => [{ "stop_tap" => stop_params }] }
546
+ }
547
+ execute_swml(swml_doc)
548
+ end
549
+
550
+ # Send an SMS message via SWML.
551
+ #
552
+ # @param to_number [String] E.164 phone number
553
+ # @param from_number [String] E.164 phone number
554
+ # @param body [String, nil]
555
+ # @param media [Array<String>, nil]
556
+ # @param tags [Array<String>, nil]
557
+ # @param region [String, nil]
558
+ # @return [self]
559
+ def send_sms(to_number:, from_number:, body: nil, media: nil,
560
+ tags: nil, region: nil)
561
+ body_empty = body.nil? || (body.respond_to?(:empty?) && body.empty?)
562
+ media_empty = media.nil? || (media.respond_to?(:empty?) && media.empty?)
563
+ raise ArgumentError, "Either body or media must be provided" if body_empty && media_empty
564
+
565
+ sms_params = {
566
+ "to_number" => to_number,
567
+ "from_number" => from_number
568
+ }
569
+ sms_params["body"] = body if body && !(body.respond_to?(:empty?) && body.empty?)
570
+ sms_params["media"] = media if media && !(media.respond_to?(:empty?) && media.empty?)
571
+ sms_params["tags"] = tags if tags && !(tags.respond_to?(:empty?) && tags.empty?)
572
+ sms_params["region"] = region if region
573
+
574
+ swml_doc = {
575
+ "version" => "1.0.0",
576
+ "sections" => { "main" => [{ "send_sms" => sms_params }] }
577
+ }
578
+ execute_swml(swml_doc)
579
+ end
580
+
581
+ # Process payment using SWML pay action.
582
+ #
583
+ # @param payment_connector_url [String] URL to make payment requests to
584
+ # @param input_method [String] "dtmf" or "voice"
585
+ # @return [self]
586
+ def pay(payment_connector_url:, input_method: "dtmf",
587
+ status_url: nil, payment_method: "credit-card",
588
+ timeout: 5, max_attempts: 1, security_code: true,
589
+ postal_code: true, min_postal_code_length: 0,
590
+ token_type: "reusable", charge_amount: nil,
591
+ currency: "usd", language: "en-US", voice: "woman",
592
+ description: nil, valid_card_types: "visa mastercard amex",
593
+ parameters: nil, prompts: nil,
594
+ ai_response: 'The payment status is ${pay_result}, do not mention anything else about collecting payment if successful.')
595
+ pay_params = {
596
+ "payment_connector_url" => payment_connector_url,
597
+ "input" => input_method,
598
+ "payment_method" => payment_method,
599
+ "timeout" => timeout.to_s,
600
+ "max_attempts" => max_attempts.to_s,
601
+ "security_code" => security_code.to_s,
602
+ "min_postal_code_length" => min_postal_code_length.to_s,
603
+ "token_type" => token_type,
604
+ "currency" => currency,
605
+ "language" => language,
606
+ "voice" => voice,
607
+ "valid_card_types" => valid_card_types
608
+ }
609
+
610
+ pay_params["postal_code"] = postal_code.is_a?(String) ? postal_code : postal_code.to_s
611
+ pay_params["status_url"] = status_url if status_url
612
+ pay_params["charge_amount"] = charge_amount if charge_amount
613
+ pay_params["description"] = description if description
614
+ pay_params["parameters"] = parameters if parameters
615
+ pay_params["prompts"] = prompts if prompts
616
+
617
+ swml_doc = {
618
+ "version" => "1.0.0",
619
+ "sections" => {
620
+ "main" => [
621
+ { "set" => { "ai_response" => ai_response } },
622
+ { "pay" => pay_params }
623
+ ]
624
+ }
625
+ }
626
+ execute_swml(swml_doc)
627
+ end
628
+
629
+ # ==================================================================
630
+ # RPC Actions
631
+ # ==================================================================
632
+
633
+ # Execute a generic RPC method via SWML.
634
+ #
635
+ # @param method [String] RPC method name
636
+ # @param params [Hash, nil]
637
+ # @param call_id [String, nil]
638
+ # @param node_id [String, nil]
639
+ # @return [self]
640
+ def execute_rpc(method, params: nil, call_id: nil, node_id: nil)
641
+ rpc_params = { "method" => method }
642
+ rpc_params["call_id"] = call_id if call_id
643
+ rpc_params["node_id"] = node_id if node_id
644
+ rpc_params["params"] = params if params && !params.empty?
645
+
646
+ swml_doc = {
647
+ "version" => "1.0.0",
648
+ "sections" => { "main" => [{ "execute_rpc" => rpc_params }] }
649
+ }
650
+ execute_swml(swml_doc)
651
+ end
652
+
653
+ # Dial out to a number via RPC.
654
+ #
655
+ # @param to_number [String] E.164 phone number
656
+ # @param from_number [String] E.164 caller ID
657
+ # @param dest_swml [String] SWML URL for the outbound leg
658
+ # @param device_type [String]
659
+ # @return [self]
660
+ def rpc_dial(to_number:, from_number:, dest_swml:, device_type: "phone")
661
+ execute_rpc(
662
+ "dial",
663
+ params: {
664
+ "devices" => {
665
+ "type" => device_type,
666
+ "params" => {
667
+ "to_number" => to_number,
668
+ "from_number" => from_number
669
+ }
670
+ },
671
+ "dest_swml" => dest_swml
672
+ }
673
+ )
674
+ end
675
+
676
+ # Inject a message into an AI agent on another call.
677
+ #
678
+ # @param call_id [String]
679
+ # @param message_text [String]
680
+ # @param role [String]
681
+ # @return [self]
682
+ def rpc_ai_message(call_id, message_text, role: "system")
683
+ execute_rpc(
684
+ "ai_message",
685
+ call_id: call_id,
686
+ params: {
687
+ "role" => role,
688
+ "message_text" => message_text
689
+ }
690
+ )
691
+ end
692
+
693
+ # Unhold another call via RPC.
694
+ # @param call_id [String]
695
+ # @return [self]
696
+ def rpc_ai_unhold(call_id)
697
+ execute_rpc("ai_unhold", call_id: call_id, params: {})
698
+ end
699
+
700
+ # Queue simulated user input.
701
+ # @param text [String]
702
+ # @return [self]
703
+ def simulate_user_input(text)
704
+ add_action("user_input", text)
705
+ end
706
+
707
+ # ==================================================================
708
+ # Payment helpers (class methods)
709
+ # ==================================================================
710
+
711
+ # Create a payment prompt structure for use with +pay+.
712
+ #
713
+ # @param for_situation [String] e.g. "payment-card-number"
714
+ # @param actions [Array<Hash>] actions with "type" and "phrase" keys
715
+ # @param card_type [String, nil]
716
+ # @param error_type [String, nil]
717
+ # @return [Hash]
718
+ def self.create_payment_prompt(for_situation, actions, card_type: nil, error_type: nil)
719
+ prompt = {
720
+ "for" => for_situation,
721
+ "actions" => actions
722
+ }
723
+ prompt["card_type"] = card_type if card_type
724
+ prompt["error_type"] = error_type if error_type
725
+ prompt
726
+ end
727
+
728
+ # Create a payment action for use inside payment prompts.
729
+ #
730
+ # @param action_type [String] "Say" or "Play"
731
+ # @param phrase [String]
732
+ # @return [Hash]
733
+ def self.create_payment_action(action_type, phrase)
734
+ { "type" => action_type, "phrase" => phrase }
735
+ end
736
+
737
+ # Create a payment parameter for use with +pay+.
738
+ #
739
+ # @param name [String]
740
+ # @param value [String]
741
+ # @return [Hash]
742
+ def self.create_payment_parameter(name, value)
743
+ { "name" => name, "value" => value }
744
+ end
745
+
746
+ # ==================================================================
747
+ # Serialization
748
+ # ==================================================================
749
+
750
+ # Convert to the Hash structure expected by SWAIG.
751
+ #
752
+ # Rules:
753
+ # - +response+ always included (string)
754
+ # - +action+ only included if at least one action exists
755
+ # - +post_process+ only included if +true+ and actions exist
756
+ #
757
+ # @return [Hash]
758
+ def to_h
759
+ result = {}
760
+
761
+ result["response"] = @response if @response && !@response.empty?
762
+ result["action"] = @action if @action && !@action.empty?
763
+ result["post_process"] = true if @post_process && @action && !@action.empty?
764
+
765
+ # Ensure at least one of response or action is present
766
+ result["response"] = "Action completed." if result.empty?
767
+
768
+ result
769
+ end
770
+
771
+ # @return [String] JSON representation
772
+ def to_json(*args)
773
+ to_h.to_json(*args)
774
+ end
775
+ end
776
+ end
777
+ end