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,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
require_relative '../skill_base'
|
|
8
|
+
require_relative '../skill_registry'
|
|
9
|
+
|
|
10
|
+
module SignalWire
|
|
11
|
+
module Skills
|
|
12
|
+
module Builtin
|
|
13
|
+
# Bridge MCP servers with SWAIG functions.
|
|
14
|
+
class McpGatewaySkill < SkillBase
|
|
15
|
+
def name; 'mcp_gateway'; end
|
|
16
|
+
def description; 'Bridge MCP servers with SWAIG functions'; end
|
|
17
|
+
|
|
18
|
+
def setup
|
|
19
|
+
@gateway_url = get_param('gateway_url')
|
|
20
|
+
@auth_token = get_param('auth_token')
|
|
21
|
+
@auth_user = get_param('auth_user')
|
|
22
|
+
@auth_password = get_param('auth_password')
|
|
23
|
+
@services = get_param('services') || []
|
|
24
|
+
@tool_prefix = get_param('tool_prefix', default: 'mcp_')
|
|
25
|
+
@timeout = (get_param('request_timeout', default: 30)).to_i
|
|
26
|
+
|
|
27
|
+
return false unless @gateway_url && !@gateway_url.empty?
|
|
28
|
+
|
|
29
|
+
# Discover tools from gateway
|
|
30
|
+
@mcp_tools = discover_tools
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def register_tools
|
|
35
|
+
@mcp_tools.map do |tool|
|
|
36
|
+
{
|
|
37
|
+
name: tool[:name],
|
|
38
|
+
description: tool[:description],
|
|
39
|
+
parameters: tool[:parameters] || {},
|
|
40
|
+
handler: lambda { |args, _raw_data|
|
|
41
|
+
execute_mcp_tool(tool[:service], tool[:original_name], args)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def get_hints
|
|
48
|
+
hints = %w[MCP gateway]
|
|
49
|
+
@services.each { |s| hints << (s.is_a?(Hash) ? s['name'] : s.to_s) } if @services
|
|
50
|
+
hints
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get_global_data
|
|
54
|
+
{
|
|
55
|
+
'mcp_gateway_url' => @gateway_url,
|
|
56
|
+
'mcp_session_id' => nil,
|
|
57
|
+
'mcp_services' => @services
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def get_prompt_sections
|
|
62
|
+
service_names = @services.map { |s| s.is_a?(Hash) ? s['name'] : s.to_s }
|
|
63
|
+
[
|
|
64
|
+
{
|
|
65
|
+
'title' => 'MCP Gateway Integration',
|
|
66
|
+
'body' => "Connected MCP services: #{service_names.join(', ')}",
|
|
67
|
+
'bullets' => @mcp_tools.map { |t| "#{t[:name]}: #{t[:description]}" }
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def get_parameter_schema
|
|
73
|
+
{
|
|
74
|
+
'gateway_url' => { 'type' => 'string', 'required' => true },
|
|
75
|
+
'auth_token' => { 'type' => 'string', 'hidden' => true },
|
|
76
|
+
'auth_user' => { 'type' => 'string' },
|
|
77
|
+
'auth_password' => { 'type' => 'string', 'hidden' => true },
|
|
78
|
+
'services' => { 'type' => 'array' },
|
|
79
|
+
'tool_prefix' => { 'type' => 'string', 'default' => 'mcp_' },
|
|
80
|
+
'request_timeout' => { 'type' => 'integer', 'default' => 30 }
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def discover_tools
|
|
87
|
+
# In a real implementation, this would query the MCP gateway for available tools.
|
|
88
|
+
# For the port, we return an empty list since we don't have a gateway to query.
|
|
89
|
+
[]
|
|
90
|
+
rescue => _e
|
|
91
|
+
[]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def execute_mcp_tool(service, tool_name, args)
|
|
95
|
+
uri = URI("#{@gateway_url}/execute")
|
|
96
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
97
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
98
|
+
http.open_timeout = @timeout
|
|
99
|
+
http.read_timeout = @timeout
|
|
100
|
+
|
|
101
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
102
|
+
req['Content-Type'] = 'application/json'
|
|
103
|
+
req['Authorization'] = "Bearer #{@auth_token}" if @auth_token
|
|
104
|
+
req.basic_auth(@auth_user, @auth_password) if @auth_user
|
|
105
|
+
|
|
106
|
+
req.body = { service: service, tool: tool_name, arguments: args }.to_json
|
|
107
|
+
|
|
108
|
+
resp = http.request(req)
|
|
109
|
+
data = JSON.parse(resp.body)
|
|
110
|
+
Swaig::FunctionResult.new(data['result'] || data.to_json)
|
|
111
|
+
rescue => e
|
|
112
|
+
Swaig::FunctionResult.new("MCP tool error: #{e.message}")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
SignalWire::Skills::SkillRegistry.register('mcp_gateway') do |params|
|
|
120
|
+
SignalWire::Skills::Builtin::McpGatewaySkill.new(params)
|
|
121
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
require_relative '../skill_base'
|
|
8
|
+
require_relative '../skill_registry'
|
|
9
|
+
|
|
10
|
+
module SignalWire
|
|
11
|
+
module Skills
|
|
12
|
+
module Builtin
|
|
13
|
+
# Network/remote mode only (as per porting manifest).
|
|
14
|
+
class NativeVectorSearchSkill < SkillBase
|
|
15
|
+
def name; 'native_vector_search'; end
|
|
16
|
+
def description; 'Search document indexes using vector similarity and keyword search (local or remote)'; end
|
|
17
|
+
def supports_multiple_instances?; true; end
|
|
18
|
+
|
|
19
|
+
def setup
|
|
20
|
+
@remote_url = get_param('remote_url')
|
|
21
|
+
@index_name = get_param('index_name')
|
|
22
|
+
@tool_name = get_param('tool_name', default: 'search_knowledge')
|
|
23
|
+
@tool_desc = get_param('description', default: 'Search the local knowledge base for information')
|
|
24
|
+
@count = (get_param('count', default: 3)).to_i
|
|
25
|
+
@threshold = (get_param('similarity_threshold', default: 0.5)).to_f
|
|
26
|
+
@custom_hints = get_param('hints') || []
|
|
27
|
+
|
|
28
|
+
# Network mode requires remote_url
|
|
29
|
+
return false unless @remote_url && !@remote_url.empty?
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def instance_key; "native_vector_search_#{@tool_name}"; end
|
|
34
|
+
|
|
35
|
+
def register_tools
|
|
36
|
+
[
|
|
37
|
+
{
|
|
38
|
+
name: @tool_name,
|
|
39
|
+
description: @tool_desc,
|
|
40
|
+
parameters: {
|
|
41
|
+
'query' => { 'type' => 'string', 'description' => 'Search query' },
|
|
42
|
+
'count' => { 'type' => 'integer', 'description' => 'Number of results to return' }
|
|
43
|
+
},
|
|
44
|
+
handler: method(:handle_search)
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def get_hints
|
|
50
|
+
base = %w[search find look\ up documentation knowledge\ base]
|
|
51
|
+
base.concat(@custom_hints) if @custom_hints.is_a?(Array)
|
|
52
|
+
base
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get_parameter_schema
|
|
56
|
+
{
|
|
57
|
+
'remote_url' => { 'type' => 'string', 'required' => true },
|
|
58
|
+
'index_name' => { 'type' => 'string' },
|
|
59
|
+
'count' => { 'type' => 'integer', 'default' => 3 },
|
|
60
|
+
'similarity_threshold' => { 'type' => 'number', 'default' => 0.5 },
|
|
61
|
+
'description' => { 'type' => 'string' },
|
|
62
|
+
'hints' => { 'type' => 'array' }
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def handle_search(args, _raw_data)
|
|
69
|
+
query = (args['query'] || '').strip
|
|
70
|
+
return Swaig::FunctionResult.new('Please provide a search query.') if query.empty?
|
|
71
|
+
|
|
72
|
+
count = (args['count'] || @count).to_i
|
|
73
|
+
|
|
74
|
+
begin
|
|
75
|
+
uri = URI(@remote_url)
|
|
76
|
+
params = { query: query, count: count }
|
|
77
|
+
params[:index_name] = @index_name if @index_name
|
|
78
|
+
|
|
79
|
+
req = Net::HTTP::Post.new(uri.path.empty? ? '/' : uri.path)
|
|
80
|
+
req['Content-Type'] = 'application/json'
|
|
81
|
+
req.body = params.to_json
|
|
82
|
+
|
|
83
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
84
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
85
|
+
http.open_timeout = 10
|
|
86
|
+
http.read_timeout = 30
|
|
87
|
+
|
|
88
|
+
resp = http.request(req)
|
|
89
|
+
unless resp.is_a?(Net::HTTPSuccess)
|
|
90
|
+
return Swaig::FunctionResult.new('Sorry, the search service is unavailable right now.')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
data = JSON.parse(resp.body)
|
|
94
|
+
results = data['results'] || data['chunks'] || []
|
|
95
|
+
if results.empty?
|
|
96
|
+
return Swaig::FunctionResult.new("No results found for '#{query}'.")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
formatted = results.first(count).each_with_index.map do |r, i|
|
|
100
|
+
text = r['text'] || r['content'] || r.to_json
|
|
101
|
+
"=== RESULT #{i + 1} ===\n#{text}\n#{'=' * 50}"
|
|
102
|
+
end.join("\n\n")
|
|
103
|
+
|
|
104
|
+
Swaig::FunctionResult.new("Search results for '#{query}':\n\n#{formatted}")
|
|
105
|
+
rescue => e
|
|
106
|
+
Swaig::FunctionResult.new("Error searching: #{e.message}")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
SignalWire::Skills::SkillRegistry.register('native_vector_search') do |params|
|
|
115
|
+
SignalWire::Skills::Builtin::NativeVectorSearchSkill.new(params)
|
|
116
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../skill_base'
|
|
4
|
+
require_relative '../skill_registry'
|
|
5
|
+
|
|
6
|
+
module SignalWire
|
|
7
|
+
module Skills
|
|
8
|
+
module Builtin
|
|
9
|
+
class PlayBackgroundFileSkill < SkillBase
|
|
10
|
+
def name; 'play_background_file'; end
|
|
11
|
+
def description; 'Control background file playback'; end
|
|
12
|
+
def supports_multiple_instances?; true; end
|
|
13
|
+
|
|
14
|
+
def setup
|
|
15
|
+
@tool_name = get_param('tool_name', default: 'play_background_file')
|
|
16
|
+
@files = get_param('files')
|
|
17
|
+
return false unless @files.is_a?(Array) && !@files.empty?
|
|
18
|
+
|
|
19
|
+
@files.each do |f|
|
|
20
|
+
return false unless f.is_a?(Hash) && f['key'] && f['description'] && f['url']
|
|
21
|
+
end
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def instance_key; "play_background_file_#{@tool_name}"; end
|
|
26
|
+
|
|
27
|
+
def register_tools
|
|
28
|
+
enum_values = @files.map { |f| "start_#{f['key']}" } + ['stop']
|
|
29
|
+
descriptions = @files.map { |f| "start_#{f['key']}: #{f['description']}" }
|
|
30
|
+
descriptions << 'stop: Stop any currently playing background file'
|
|
31
|
+
param_desc = 'Action to perform. Options: ' + descriptions.join('; ')
|
|
32
|
+
|
|
33
|
+
expressions = @files.map do |f|
|
|
34
|
+
result = Swaig::FunctionResult.new(
|
|
35
|
+
"Tell the user you are now going to play #{f['description']} for them."
|
|
36
|
+
)
|
|
37
|
+
result.set_post_process(true)
|
|
38
|
+
result.play_background_file(f['url'], wait: f.fetch('wait', false))
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
'string' => '${args.action}',
|
|
42
|
+
'pattern' => "/start_#{f['key']}/i",
|
|
43
|
+
'output' => result.to_h
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
stop_result = Swaig::FunctionResult.new(
|
|
48
|
+
'Tell the user you have stopped the background file playback.'
|
|
49
|
+
).stop_background_file
|
|
50
|
+
|
|
51
|
+
expressions << {
|
|
52
|
+
'string' => '${args.action}',
|
|
53
|
+
'pattern' => '/stop/i',
|
|
54
|
+
'output' => stop_result.to_h
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
tool = {
|
|
58
|
+
'function' => @tool_name,
|
|
59
|
+
'description' => "Control background file playback for #{@tool_name.tr('_', ' ')}",
|
|
60
|
+
'parameters' => {
|
|
61
|
+
'type' => 'object',
|
|
62
|
+
'properties' => {
|
|
63
|
+
'action' => { 'type' => 'string', 'description' => param_desc, 'enum' => enum_values }
|
|
64
|
+
},
|
|
65
|
+
'required' => ['action']
|
|
66
|
+
},
|
|
67
|
+
'data_map' => { 'expressions' => expressions }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
[{ datamap: tool }]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def get_parameter_schema
|
|
74
|
+
{
|
|
75
|
+
'files' => { 'type' => 'array', 'required' => true,
|
|
76
|
+
'items' => { 'type' => 'object', 'required' => %w[key description url] } }
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
SignalWire::Skills::SkillRegistry.register('play_background_file') do |params|
|
|
85
|
+
SignalWire::Skills::Builtin::PlayBackgroundFileSkill.new(params)
|
|
86
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
require_relative '../skill_base'
|
|
7
|
+
require_relative '../skill_registry'
|
|
8
|
+
|
|
9
|
+
module SignalWire
|
|
10
|
+
module Skills
|
|
11
|
+
module Builtin
|
|
12
|
+
class SpiderSkill < SkillBase
|
|
13
|
+
def name; 'spider'; end
|
|
14
|
+
def description; 'Fast web scraping and crawling capabilities'; end
|
|
15
|
+
def supports_multiple_instances?; true; end
|
|
16
|
+
|
|
17
|
+
def setup
|
|
18
|
+
@max_text_length = (get_param('max_text_length', default: 10_000)).to_i
|
|
19
|
+
@timeout = (get_param('timeout', default: 5)).to_i
|
|
20
|
+
@user_agent = get_param('user_agent', default: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36')
|
|
21
|
+
@tool_prefix = get_param('tool_name', default: '')
|
|
22
|
+
@tool_prefix = "#{@tool_prefix}_" unless @tool_prefix.empty?
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def instance_key
|
|
27
|
+
"spider_#{get_param('tool_name', default: 'spider')}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def register_tools
|
|
31
|
+
[
|
|
32
|
+
{
|
|
33
|
+
name: "#{@tool_prefix}scrape_url",
|
|
34
|
+
description: 'Extract text content from a single web page',
|
|
35
|
+
parameters: { 'url' => { 'type' => 'string', 'description' => 'The URL to scrape' } },
|
|
36
|
+
handler: method(:handle_scrape)
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "#{@tool_prefix}crawl_site",
|
|
40
|
+
description: 'Crawl multiple pages starting from a URL',
|
|
41
|
+
parameters: { 'start_url' => { 'type' => 'string', 'description' => 'Starting URL for the crawl' } },
|
|
42
|
+
handler: method(:handle_crawl)
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "#{@tool_prefix}extract_structured_data",
|
|
46
|
+
description: 'Extract specific data from a web page using selectors',
|
|
47
|
+
parameters: { 'url' => { 'type' => 'string', 'description' => 'The URL to scrape' } },
|
|
48
|
+
handler: method(:handle_extract)
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get_hints
|
|
54
|
+
%w[scrape crawl extract web\ page website spider]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def get_parameter_schema
|
|
58
|
+
{
|
|
59
|
+
'timeout' => { 'type' => 'integer', 'default' => 5 },
|
|
60
|
+
'max_text_length' => { 'type' => 'integer', 'default' => 10_000 },
|
|
61
|
+
'user_agent' => { 'type' => 'string' }
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def handle_scrape(args, _raw_data)
|
|
68
|
+
url = (args['url'] || '').strip
|
|
69
|
+
return Swaig::FunctionResult.new('Please provide a URL to scrape') if url.empty?
|
|
70
|
+
|
|
71
|
+
text = fetch_text(url)
|
|
72
|
+
if text.nil? || text.empty?
|
|
73
|
+
return Swaig::FunctionResult.new("Failed to fetch or no content from #{url}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
Swaig::FunctionResult.new("Content from #{url} (#{text.length} characters):\n\n#{text}")
|
|
77
|
+
rescue => e
|
|
78
|
+
Swaig::FunctionResult.new("Error scraping #{url}: #{e.message}")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def handle_crawl(args, _raw_data)
|
|
82
|
+
url = (args['start_url'] || '').strip
|
|
83
|
+
return Swaig::FunctionResult.new('Please provide a starting URL for the crawl') if url.empty?
|
|
84
|
+
|
|
85
|
+
text = fetch_text(url)
|
|
86
|
+
if text.nil? || text.empty?
|
|
87
|
+
return Swaig::FunctionResult.new("No pages could be crawled from #{url}")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
summary = text.length > 500 ? text[0, 500] + '...' : text
|
|
91
|
+
Swaig::FunctionResult.new("Crawled 1 page from #{URI(url).host}:\n\n1. #{url} (#{text.length} chars)\n Summary: #{summary}")
|
|
92
|
+
rescue => e
|
|
93
|
+
Swaig::FunctionResult.new("Error crawling #{url}: #{e.message}")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def handle_extract(args, _raw_data)
|
|
97
|
+
url = (args['url'] || '').strip
|
|
98
|
+
return Swaig::FunctionResult.new('Please provide a URL') if url.empty?
|
|
99
|
+
|
|
100
|
+
text = fetch_text(url)
|
|
101
|
+
if text.nil? || text.empty?
|
|
102
|
+
return Swaig::FunctionResult.new("Failed to fetch #{url}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
Swaig::FunctionResult.new("Extracted data from #{url}:\n\nContent: #{text[0, 2000]}")
|
|
106
|
+
rescue => e
|
|
107
|
+
Swaig::FunctionResult.new("Error extracting data: #{e.message}")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def fetch_text(url)
|
|
111
|
+
# SPIDER_BASE_URL redirects every fetch through a configured host
|
|
112
|
+
# (used by audit_skills_dispatch.py to point the skill at a
|
|
113
|
+
# loopback fixture). The path/query of the user-supplied URL is
|
|
114
|
+
# preserved so the audit can match on it.
|
|
115
|
+
base = ENV['SPIDER_BASE_URL']
|
|
116
|
+
if base && !base.empty?
|
|
117
|
+
url = "#{base.sub(/\/$/, '')}#{_url_path(url)}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
uri = URI(url)
|
|
121
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
122
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
123
|
+
http.open_timeout = @timeout
|
|
124
|
+
http.read_timeout = @timeout
|
|
125
|
+
|
|
126
|
+
req = Net::HTTP::Get.new(uri)
|
|
127
|
+
req['User-Agent'] = @user_agent
|
|
128
|
+
|
|
129
|
+
resp = http.request(req)
|
|
130
|
+
return nil unless resp.is_a?(Net::HTTPSuccess)
|
|
131
|
+
|
|
132
|
+
body = resp.body.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
|
|
133
|
+
# Some upstreams (and the audit fixture) wrap the HTML in JSON
|
|
134
|
+
# under an `_raw_html` field; unwrap before stripping tags.
|
|
135
|
+
begin
|
|
136
|
+
parsed = JSON.parse(body)
|
|
137
|
+
if parsed.is_a?(Hash) && parsed['_raw_html'].is_a?(String)
|
|
138
|
+
body = parsed['_raw_html']
|
|
139
|
+
end
|
|
140
|
+
rescue JSON::ParserError
|
|
141
|
+
# not JSON — treat as raw HTML
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Strip HTML tags
|
|
145
|
+
text = body.gsub(/<script[^>]*>.*?<\/script>/mi, '')
|
|
146
|
+
.gsub(/<style[^>]*>.*?<\/style>/mi, '')
|
|
147
|
+
.gsub(/<[^>]+>/, ' ')
|
|
148
|
+
.gsub(/\s+/, ' ')
|
|
149
|
+
.strip
|
|
150
|
+
|
|
151
|
+
text.length > @max_text_length ? text[0, @max_text_length] : text
|
|
152
|
+
rescue => _e
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Extract the path-and-query portion of a URL. Used by
|
|
157
|
+
# SPIDER_BASE_URL redirection to preserve audit fixture matching.
|
|
158
|
+
def _url_path(url)
|
|
159
|
+
stripped = url.sub(%r{\Ahttps?://[^/]+}, '')
|
|
160
|
+
stripped.empty? ? '/' : stripped
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
SignalWire::Skills::SkillRegistry.register('spider') do |params|
|
|
168
|
+
SignalWire::Skills::Builtin::SpiderSkill.new(params)
|
|
169
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../skill_base'
|
|
4
|
+
require_relative '../skill_registry'
|
|
5
|
+
require_relative '../../datamap/data_map'
|
|
6
|
+
|
|
7
|
+
module SignalWire
|
|
8
|
+
module Skills
|
|
9
|
+
module Builtin
|
|
10
|
+
class SwmlTransferSkill < SkillBase
|
|
11
|
+
def name; 'swml_transfer'; end
|
|
12
|
+
def description; 'Transfer calls between agents based on pattern matching'; end
|
|
13
|
+
def supports_multiple_instances?; true; end
|
|
14
|
+
|
|
15
|
+
def setup
|
|
16
|
+
@transfers = get_param('transfers')
|
|
17
|
+
return false unless @transfers.is_a?(Hash) && !@transfers.empty?
|
|
18
|
+
|
|
19
|
+
@tool_name = get_param('tool_name', default: 'transfer_call')
|
|
20
|
+
@desc = get_param('description', default: 'Transfer call based on pattern matching')
|
|
21
|
+
@param_name = get_param('parameter_name', default: 'transfer_type')
|
|
22
|
+
@param_desc = get_param('parameter_description', default: 'The type of transfer to perform')
|
|
23
|
+
@default_message = get_param('default_message', default: 'Please specify a valid transfer type.')
|
|
24
|
+
@required_fields = get_param('required_fields') || {}
|
|
25
|
+
|
|
26
|
+
# Validate each transfer
|
|
27
|
+
@transfers.each do |pattern, config|
|
|
28
|
+
return false unless config.is_a?(Hash)
|
|
29
|
+
return false unless config.key?('url') || config.key?('address')
|
|
30
|
+
config['message'] ||= 'Transferring you now...'
|
|
31
|
+
config['return_message'] ||= 'The transfer is complete. How else can I help you?'
|
|
32
|
+
config['post_process'] = true unless config.key?('post_process')
|
|
33
|
+
config['final'] = true unless config.key?('final')
|
|
34
|
+
end
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def instance_key; "swml_transfer_#{@tool_name}"; end
|
|
39
|
+
|
|
40
|
+
def register_tools
|
|
41
|
+
dm = DataMap.new(@tool_name)
|
|
42
|
+
.description(@desc)
|
|
43
|
+
.parameter(@param_name, 'string', @param_desc, required: true)
|
|
44
|
+
|
|
45
|
+
@required_fields.each do |field, field_desc|
|
|
46
|
+
dm.parameter(field, 'string', field_desc, required: true)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@transfers.each do |pattern, config|
|
|
50
|
+
result = Swaig::FunctionResult.new(config['message'])
|
|
51
|
+
result.set_post_process(config['post_process'])
|
|
52
|
+
|
|
53
|
+
if config.key?('url')
|
|
54
|
+
result.swml_transfer(config['url'], config['return_message'], final: config['final'])
|
|
55
|
+
else
|
|
56
|
+
result.connect(config['address'], final: config['final'], from_addr: config['from_addr'])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
dm.expression("${args.#{@param_name}}", pattern, result)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Default fallback
|
|
63
|
+
default_result = Swaig::FunctionResult.new(@default_message)
|
|
64
|
+
dm.expression("${args.#{@param_name}}", '/.*/', default_result)
|
|
65
|
+
|
|
66
|
+
[{ datamap: dm.to_swaig_function }]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def get_hints
|
|
70
|
+
hints = []
|
|
71
|
+
@transfers&.each_key do |pattern|
|
|
72
|
+
clean = pattern.gsub(%r{^/|/[i]*$}, '')
|
|
73
|
+
next if clean.empty? || clean.start_with?('.')
|
|
74
|
+
if clean.include?('|')
|
|
75
|
+
clean.split('|').each { |p| hints << p.strip.downcase }
|
|
76
|
+
else
|
|
77
|
+
hints << clean.downcase
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
hints.concat(%w[transfer connect speak\ to talk\ to])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def get_prompt_sections
|
|
84
|
+
return [] unless @transfers && !@transfers.empty?
|
|
85
|
+
|
|
86
|
+
bullets = @transfers.map do |pattern, config|
|
|
87
|
+
clean = pattern.gsub(%r{^/|/[i]*$}, '')
|
|
88
|
+
dest = config['url'] || config['address']
|
|
89
|
+
"\"#{clean}\" - transfers to #{dest}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
[
|
|
93
|
+
{ 'title' => 'Transferring', 'body' => "Transfer calls using #{@tool_name}.", 'bullets' => bullets },
|
|
94
|
+
{ 'title' => 'Transfer Instructions', 'body' => 'How to use the transfer capability:',
|
|
95
|
+
'bullets' => [
|
|
96
|
+
"Use the #{@tool_name} function when a transfer is needed",
|
|
97
|
+
"Pass the destination type to the '#{@param_name}' parameter"
|
|
98
|
+
] }
|
|
99
|
+
]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def get_parameter_schema
|
|
103
|
+
{
|
|
104
|
+
'transfers' => { 'type' => 'object', 'required' => true },
|
|
105
|
+
'description' => { 'type' => 'string', 'default' => 'Transfer call based on pattern matching' },
|
|
106
|
+
'parameter_name' => { 'type' => 'string', 'default' => 'transfer_type' },
|
|
107
|
+
'default_message' => { 'type' => 'string', 'default' => 'Please specify a valid transfer type.' },
|
|
108
|
+
'required_fields' => { 'type' => 'object', 'default' => {} }
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
SignalWire::Skills::SkillRegistry.register('swml_transfer') do |params|
|
|
117
|
+
SignalWire::Skills::Builtin::SwmlTransferSkill.new(params)
|
|
118
|
+
end
|