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,168 @@
|
|
|
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
|
+
class GoogleMapsSkill < SkillBase
|
|
14
|
+
def name; 'google_maps'; end
|
|
15
|
+
def description; 'Validate addresses and compute driving routes using Google Maps'; end
|
|
16
|
+
|
|
17
|
+
def setup
|
|
18
|
+
@api_key = get_param('api_key', env_var: 'GOOGLE_MAPS_API_KEY')
|
|
19
|
+
@lookup_tool = get_param('lookup_tool_name', default: 'lookup_address')
|
|
20
|
+
@route_tool = get_param('route_tool_name', default: 'compute_route')
|
|
21
|
+
return false unless @api_key && !@api_key.empty?
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def register_tools
|
|
26
|
+
[
|
|
27
|
+
{
|
|
28
|
+
name: @lookup_tool,
|
|
29
|
+
description: 'Validate and geocode a street address or business name using Google Maps',
|
|
30
|
+
parameters: {
|
|
31
|
+
'address' => { 'type' => 'string', 'description' => 'The address or business name to look up' },
|
|
32
|
+
'bias_lat' => { 'type' => 'number', 'description' => 'Latitude to bias results toward (optional)' },
|
|
33
|
+
'bias_lng' => { 'type' => 'number', 'description' => 'Longitude to bias results toward (optional)' }
|
|
34
|
+
},
|
|
35
|
+
handler: method(:handle_lookup)
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: @route_tool,
|
|
39
|
+
description: 'Compute a driving route between two points using Google Maps Routes API',
|
|
40
|
+
parameters: {
|
|
41
|
+
'origin_lat' => { 'type' => 'number', 'description' => 'Origin latitude' },
|
|
42
|
+
'origin_lng' => { 'type' => 'number', 'description' => 'Origin longitude' },
|
|
43
|
+
'dest_lat' => { 'type' => 'number', 'description' => 'Destination latitude' },
|
|
44
|
+
'dest_lng' => { 'type' => 'number', 'description' => 'Destination longitude' }
|
|
45
|
+
},
|
|
46
|
+
handler: method(:handle_route)
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def get_hints
|
|
52
|
+
%w[address location route directions miles distance]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get_prompt_sections
|
|
56
|
+
[
|
|
57
|
+
{
|
|
58
|
+
'title' => 'Google Maps',
|
|
59
|
+
'body' => 'You can validate addresses and compute driving routes.',
|
|
60
|
+
'bullets' => [
|
|
61
|
+
"Use #{@lookup_tool} to validate and geocode addresses or business names",
|
|
62
|
+
"Use #{@route_tool} to get driving distance and time between two points",
|
|
63
|
+
"Address lookup supports spoken numbers (e.g. 'seven one four' becomes '714')",
|
|
64
|
+
'You can bias address results toward a known location to find the nearest match'
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def get_parameter_schema
|
|
71
|
+
{
|
|
72
|
+
'api_key' => { 'type' => 'string', 'required' => true, 'hidden' => true, 'env_var' => 'GOOGLE_MAPS_API_KEY' },
|
|
73
|
+
'lookup_tool_name' => { 'type' => 'string', 'default' => 'lookup_address' },
|
|
74
|
+
'route_tool_name' => { 'type' => 'string', 'default' => 'compute_route' }
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def handle_lookup(args, _raw_data)
|
|
81
|
+
address = (args['address'] || '').strip
|
|
82
|
+
if address.empty?
|
|
83
|
+
return Swaig::FunctionResult.new('Please provide an address or business name to look up.')
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
bias_lat = args['bias_lat']
|
|
87
|
+
bias_lng = args['bias_lng']
|
|
88
|
+
|
|
89
|
+
# Use Geocoding API
|
|
90
|
+
params = { address: address, key: @api_key }
|
|
91
|
+
uri = URI('https://maps.googleapis.com/maps/api/geocode/json')
|
|
92
|
+
uri.query = URI.encode_www_form(params)
|
|
93
|
+
|
|
94
|
+
resp = Net::HTTP.get_response(uri)
|
|
95
|
+
unless resp.is_a?(Net::HTTPSuccess)
|
|
96
|
+
return Swaig::FunctionResult.new("I couldn't find that address. Could you provide a more specific address?")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
data = JSON.parse(resp.body)
|
|
100
|
+
results = data['results'] || []
|
|
101
|
+
if results.empty?
|
|
102
|
+
return Swaig::FunctionResult.new("I couldn't find that address. Could you provide a more specific address?")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
r = results.first
|
|
106
|
+
location = r.dig('geometry', 'location') || {}
|
|
107
|
+
formatted = r['formatted_address'] || address
|
|
108
|
+
|
|
109
|
+
Swaig::FunctionResult.new(
|
|
110
|
+
"Address: #{formatted}\nCoordinates: #{location['lat']}, #{location['lng']}"
|
|
111
|
+
)
|
|
112
|
+
rescue => e
|
|
113
|
+
Swaig::FunctionResult.new("Error looking up address: #{e.message}")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def handle_route(args, _raw_data)
|
|
117
|
+
origin_lat = args['origin_lat']
|
|
118
|
+
origin_lng = args['origin_lng']
|
|
119
|
+
dest_lat = args['dest_lat']
|
|
120
|
+
dest_lng = args['dest_lng']
|
|
121
|
+
|
|
122
|
+
if [origin_lat, origin_lng, dest_lat, dest_lng].any?(&:nil?)
|
|
123
|
+
return Swaig::FunctionResult.new('All four coordinates are required: origin_lat, origin_lng, dest_lat, dest_lng.')
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
uri = URI('https://routes.googleapis.com/directions/v2:computeRoutes')
|
|
127
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
128
|
+
http.use_ssl = true
|
|
129
|
+
|
|
130
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
131
|
+
request['Content-Type'] = 'application/json'
|
|
132
|
+
request['X-Goog-Api-Key'] = @api_key
|
|
133
|
+
request['X-Goog-FieldMask'] = 'routes.distanceMeters,routes.duration'
|
|
134
|
+
request.body = {
|
|
135
|
+
origin: { location: { latLng: { latitude: origin_lat, longitude: origin_lng } } },
|
|
136
|
+
destination: { location: { latLng: { latitude: dest_lat, longitude: dest_lng } } },
|
|
137
|
+
travelMode: 'DRIVE',
|
|
138
|
+
routingPreference: 'TRAFFIC_AWARE'
|
|
139
|
+
}.to_json
|
|
140
|
+
|
|
141
|
+
resp = http.request(request)
|
|
142
|
+
data = JSON.parse(resp.body)
|
|
143
|
+
|
|
144
|
+
routes = data['routes'] || []
|
|
145
|
+
if routes.empty?
|
|
146
|
+
return Swaig::FunctionResult.new("I couldn't compute a route between those locations.")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
route = routes.first
|
|
150
|
+
distance_m = route['distanceMeters'] || 0
|
|
151
|
+
duration_s = (route['duration'] || '0s').to_s.delete('s').to_i
|
|
152
|
+
distance_mi = distance_m / 1609.344
|
|
153
|
+
duration_min = duration_s / 60.0
|
|
154
|
+
|
|
155
|
+
Swaig::FunctionResult.new(
|
|
156
|
+
"Distance: #{'%.1f' % distance_mi} miles\nEstimated travel time: #{duration_min.to_i} minutes"
|
|
157
|
+
)
|
|
158
|
+
rescue => e
|
|
159
|
+
Swaig::FunctionResult.new("Error computing route: #{e.message}")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
SignalWire::Skills::SkillRegistry.register('google_maps') do |params|
|
|
167
|
+
SignalWire::Skills::Builtin::GoogleMapsSkill.new(params)
|
|
168
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
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 InfoGathererSkill < SkillBase
|
|
10
|
+
def name; 'info_gatherer'; end
|
|
11
|
+
def description; 'Gather answers to a configurable list of questions'; end
|
|
12
|
+
def supports_multiple_instances?; true; end
|
|
13
|
+
|
|
14
|
+
def setup
|
|
15
|
+
@questions = get_param('questions')
|
|
16
|
+
return false unless @questions.is_a?(Array) && !@questions.empty?
|
|
17
|
+
|
|
18
|
+
@questions.each_with_index do |q, i|
|
|
19
|
+
return false unless q.is_a?(Hash) && q['key_name'] && q['question_text']
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
prefix = get_param('prefix')
|
|
23
|
+
if prefix && !prefix.empty?
|
|
24
|
+
@start_tool = "#{prefix}_start_questions"
|
|
25
|
+
@submit_tool = "#{prefix}_submit_answer"
|
|
26
|
+
@namespace = "skill:#{prefix}"
|
|
27
|
+
else
|
|
28
|
+
@start_tool = 'start_questions'
|
|
29
|
+
@submit_tool = 'submit_answer'
|
|
30
|
+
@namespace = 'skill:info_gatherer'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@completion_message = get_param('completion_message',
|
|
34
|
+
default: 'Thank you! All questions have been answered.')
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def instance_key
|
|
39
|
+
prefix = get_param('prefix')
|
|
40
|
+
prefix && !prefix.to_s.empty? ? "info_gatherer_#{prefix}" : 'info_gatherer'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def register_tools
|
|
44
|
+
[
|
|
45
|
+
{
|
|
46
|
+
name: @start_tool,
|
|
47
|
+
description: 'Start the question sequence with the first question',
|
|
48
|
+
parameters: {},
|
|
49
|
+
handler: method(:handle_start)
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: @submit_tool,
|
|
53
|
+
description: 'Submit an answer to the current question and move to the next one',
|
|
54
|
+
parameters: {
|
|
55
|
+
'answer' => { 'type' => 'string', 'description' => "The user's answer to the current question" },
|
|
56
|
+
'confirmed_by_user' => { 'type' => 'boolean', 'description' => 'Only set to true when the user has explicitly confirmed the answer.' }
|
|
57
|
+
},
|
|
58
|
+
handler: method(:handle_submit)
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def get_global_data
|
|
64
|
+
{
|
|
65
|
+
@namespace => {
|
|
66
|
+
'questions' => @questions,
|
|
67
|
+
'question_index' => 0,
|
|
68
|
+
'answers' => []
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def get_prompt_sections
|
|
74
|
+
[
|
|
75
|
+
{
|
|
76
|
+
'title' => "Info Gatherer (#{instance_key})",
|
|
77
|
+
'body' => "You need to gather answers to a series of questions from the user. " \
|
|
78
|
+
"Start by asking if they are ready, then call #{@start_tool} to get the first question. " \
|
|
79
|
+
"After each answer, call #{@submit_tool} to record it and get the next question."
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def get_parameter_schema
|
|
85
|
+
{
|
|
86
|
+
'questions' => { 'type' => 'array', 'required' => true },
|
|
87
|
+
'prefix' => { 'type' => 'string' },
|
|
88
|
+
'completion_message' => { 'type' => 'string' }
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def handle_start(args, raw_data)
|
|
95
|
+
state = extract_state(raw_data)
|
|
96
|
+
questions = state['questions'] || @questions
|
|
97
|
+
index = state['question_index'] || 0
|
|
98
|
+
|
|
99
|
+
if questions.empty? || index >= questions.size
|
|
100
|
+
return Swaig::FunctionResult.new("I don't have any questions to ask.")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
current = questions[index]
|
|
104
|
+
instruction = generate_instruction(current, index, questions.size, true)
|
|
105
|
+
Swaig::FunctionResult.new(instruction)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def handle_submit(args, raw_data)
|
|
109
|
+
answer = args['answer'] || ''
|
|
110
|
+
confirmed = args['confirmed_by_user']
|
|
111
|
+
|
|
112
|
+
state = extract_state(raw_data)
|
|
113
|
+
questions = state['questions'] || @questions
|
|
114
|
+
index = state['question_index'] || 0
|
|
115
|
+
answers = state['answers'] || []
|
|
116
|
+
|
|
117
|
+
if index >= questions.size
|
|
118
|
+
return Swaig::FunctionResult.new('All questions have already been answered.')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
current = questions[index]
|
|
122
|
+
|
|
123
|
+
if current['confirm'] && !confirmed
|
|
124
|
+
return Swaig::FunctionResult.new(
|
|
125
|
+
"Before submitting, read the answer \"#{answer}\" back to the user and ask them to confirm."
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
new_answers = answers + [{ 'key_name' => current['key_name'], 'answer' => answer }]
|
|
130
|
+
new_index = index + 1
|
|
131
|
+
|
|
132
|
+
if new_index < questions.size
|
|
133
|
+
next_q = questions[new_index]
|
|
134
|
+
instruction = generate_instruction(next_q, new_index, questions.size, false)
|
|
135
|
+
result = Swaig::FunctionResult.new(instruction)
|
|
136
|
+
else
|
|
137
|
+
result = Swaig::FunctionResult.new(@completion_message)
|
|
138
|
+
result.toggle_functions([
|
|
139
|
+
{ 'function' => @start_tool, 'active' => false },
|
|
140
|
+
{ 'function' => @submit_tool, 'active' => false }
|
|
141
|
+
])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
result.update_global_data({
|
|
145
|
+
@namespace => {
|
|
146
|
+
'questions' => questions,
|
|
147
|
+
'question_index' => new_index,
|
|
148
|
+
'answers' => new_answers
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
result
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def extract_state(raw_data)
|
|
155
|
+
return {} unless raw_data.is_a?(Hash)
|
|
156
|
+
gd = raw_data['global_data'] || {}
|
|
157
|
+
gd[@namespace] || {}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def generate_instruction(question, index, total, first)
|
|
161
|
+
text = question['question_text']
|
|
162
|
+
num = index + 1
|
|
163
|
+
|
|
164
|
+
if first
|
|
165
|
+
instr = "Ask each question one at a time, wait for the user's answer, " \
|
|
166
|
+
"then call #{@submit_tool} with their answer.\n\n" \
|
|
167
|
+
"[Question #{num} of #{total}]: \"#{text}\""
|
|
168
|
+
else
|
|
169
|
+
instr = "Previous answer saved. [Question #{num} of #{total}]: \"#{text}\""
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if question['prompt_add'] && !question['prompt_add'].empty?
|
|
173
|
+
instr += "\nNote: #{question['prompt_add']}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
if question['confirm']
|
|
177
|
+
instr += "\nThis question requires confirmation. Read the answer back and ask the user to confirm."
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
instr
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
SignalWire::Skills::SkillRegistry.register('info_gatherer') do |params|
|
|
188
|
+
SignalWire::Skills::Builtin::InfoGathererSkill.new(params)
|
|
189
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
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 JokeSkill < SkillBase
|
|
11
|
+
def name; 'joke'; end
|
|
12
|
+
def description; 'Tell jokes using the API Ninjas joke API'; end
|
|
13
|
+
|
|
14
|
+
def setup
|
|
15
|
+
@api_key = get_param('api_key', env_var: 'API_NINJAS_KEY')
|
|
16
|
+
@tool_name = get_param('tool_name', default: 'get_joke')
|
|
17
|
+
return false unless @api_key && !@api_key.empty?
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def register_tools
|
|
22
|
+
dm = DataMap.new(@tool_name)
|
|
23
|
+
.description('Get a random joke from API Ninjas')
|
|
24
|
+
.parameter('type', 'string', 'Type of joke to get', required: true, enum: %w[jokes dadjokes])
|
|
25
|
+
.webhook('GET', "https://api.api-ninjas.com/v1/${args.type}",
|
|
26
|
+
headers: { 'X-Api-Key' => @api_key })
|
|
27
|
+
.output(Swaig::FunctionResult.new("Here's a joke: ${array[0].joke}"))
|
|
28
|
+
.error_keys(%w[error])
|
|
29
|
+
.fallback_output(Swaig::FunctionResult.new('Sorry, there is a problem with the joke service right now. Please try again later.'))
|
|
30
|
+
|
|
31
|
+
[{ datamap: dm.to_swaig_function }]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def get_global_data
|
|
35
|
+
{ 'joke_skill_enabled' => true }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def get_prompt_sections
|
|
39
|
+
[
|
|
40
|
+
{
|
|
41
|
+
'title' => 'Joke Telling',
|
|
42
|
+
'body' => 'You can tell jokes to entertain users.',
|
|
43
|
+
'bullets' => [
|
|
44
|
+
"Use #{@tool_name || 'get_joke'} to tell jokes when users ask for humor",
|
|
45
|
+
'You can tell regular jokes or dad jokes',
|
|
46
|
+
'Be enthusiastic and fun when sharing jokes'
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def get_parameter_schema
|
|
53
|
+
{
|
|
54
|
+
'api_key' => { 'type' => 'string', 'required' => true, 'hidden' => true, 'env_var' => 'API_NINJAS_KEY' },
|
|
55
|
+
'tool_name' => { 'type' => 'string', 'default' => 'get_joke' }
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
SignalWire::Skills::SkillRegistry.register('joke') do |params|
|
|
64
|
+
SignalWire::Skills::Builtin::JokeSkill.new(params)
|
|
65
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
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 MathSkill < SkillBase
|
|
10
|
+
def name; 'math'; end
|
|
11
|
+
def description; 'Perform basic mathematical calculations'; end
|
|
12
|
+
|
|
13
|
+
def register_tools
|
|
14
|
+
[
|
|
15
|
+
{
|
|
16
|
+
name: 'calculate',
|
|
17
|
+
description: 'Perform a mathematical calculation with basic operations (+, -, *, /, %, **)',
|
|
18
|
+
parameters: {
|
|
19
|
+
'expression' => { 'type' => 'string', 'description' => "Mathematical expression to evaluate (e.g., '2 + 3 * 4', '(10 + 5) / 3')" }
|
|
20
|
+
},
|
|
21
|
+
handler: method(:handle_calculate)
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get_prompt_sections
|
|
27
|
+
[
|
|
28
|
+
{
|
|
29
|
+
'title' => 'Mathematical Calculations',
|
|
30
|
+
'body' => 'You can perform mathematical calculations for users.',
|
|
31
|
+
'bullets' => [
|
|
32
|
+
'Use the calculate tool for any math expressions',
|
|
33
|
+
'Supports basic operations: +, -, *, /, %, ** (power)',
|
|
34
|
+
'Can handle parentheses for complex expressions'
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Safe expression evaluator. Only allows numbers and basic operators.
|
|
43
|
+
# Never calls eval on untrusted input.
|
|
44
|
+
def handle_calculate(args, _raw_data)
|
|
45
|
+
expression = (args['expression'] || '').strip
|
|
46
|
+
if expression.empty?
|
|
47
|
+
return Swaig::FunctionResult.new('Please provide a mathematical expression to calculate.')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
result = safe_eval(expression)
|
|
51
|
+
Swaig::FunctionResult.new("#{expression} = #{result}")
|
|
52
|
+
rescue ZeroDivisionError
|
|
53
|
+
Swaig::FunctionResult.new('Error: Division by zero is not allowed.')
|
|
54
|
+
rescue => _e
|
|
55
|
+
Swaig::FunctionResult.new('Error: Invalid expression. Only numbers and basic math operators (+, -, *, /, %, **, parentheses) are allowed.')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Tokenize, parse, and evaluate a mathematical expression safely.
|
|
59
|
+
# This uses a simple recursive-descent parser — no eval/exec.
|
|
60
|
+
def safe_eval(expr)
|
|
61
|
+
tokens = tokenize(expr)
|
|
62
|
+
pos = [0]
|
|
63
|
+
result = parse_expr(tokens, pos)
|
|
64
|
+
raise 'Unexpected tokens after expression' unless pos[0] >= tokens.length
|
|
65
|
+
result
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def tokenize(expr)
|
|
69
|
+
tokens = []
|
|
70
|
+
i = 0
|
|
71
|
+
while i < expr.length
|
|
72
|
+
ch = expr[i]
|
|
73
|
+
if ch =~ /\s/
|
|
74
|
+
i += 1
|
|
75
|
+
elsif ch =~ /[\d.]/ || (ch == '-' && (tokens.empty? || %w[( + - * / % **].include?(tokens.last)))
|
|
76
|
+
num_str = +''
|
|
77
|
+
if ch == '-'
|
|
78
|
+
num_str << ch
|
|
79
|
+
i += 1
|
|
80
|
+
end
|
|
81
|
+
while i < expr.length && expr[i] =~ /[\d.]/
|
|
82
|
+
num_str << expr[i]
|
|
83
|
+
i += 1
|
|
84
|
+
end
|
|
85
|
+
tokens << num_str
|
|
86
|
+
elsif ch == '*' && i + 1 < expr.length && expr[i + 1] == '*'
|
|
87
|
+
tokens << '**'
|
|
88
|
+
i += 2
|
|
89
|
+
elsif '+-*/%()'.include?(ch)
|
|
90
|
+
tokens << ch
|
|
91
|
+
i += 1
|
|
92
|
+
else
|
|
93
|
+
raise "Invalid character: #{ch}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
tokens
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_expr(tokens, pos)
|
|
100
|
+
left = parse_term(tokens, pos)
|
|
101
|
+
while pos[0] < tokens.length && %w[+ -].include?(tokens[pos[0]])
|
|
102
|
+
op = tokens[pos[0]]
|
|
103
|
+
pos[0] += 1
|
|
104
|
+
right = parse_term(tokens, pos)
|
|
105
|
+
left = op == '+' ? left + right : left - right
|
|
106
|
+
end
|
|
107
|
+
left
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def parse_term(tokens, pos)
|
|
111
|
+
left = parse_power(tokens, pos)
|
|
112
|
+
while pos[0] < tokens.length && %w[* / %].include?(tokens[pos[0]])
|
|
113
|
+
op = tokens[pos[0]]
|
|
114
|
+
pos[0] += 1
|
|
115
|
+
right = parse_power(tokens, pos)
|
|
116
|
+
case op
|
|
117
|
+
when '*' then left *= right
|
|
118
|
+
when '/'
|
|
119
|
+
raise ZeroDivisionError, 'division by zero' if right == 0
|
|
120
|
+
left = left.to_f / right
|
|
121
|
+
when '%'
|
|
122
|
+
raise ZeroDivisionError, 'division by zero' if right == 0
|
|
123
|
+
left %= right
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
left
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse_power(tokens, pos)
|
|
130
|
+
base = parse_unary(tokens, pos)
|
|
131
|
+
if pos[0] < tokens.length && tokens[pos[0]] == '**'
|
|
132
|
+
pos[0] += 1
|
|
133
|
+
exp = parse_power(tokens, pos) # right-associative
|
|
134
|
+
raise 'Exponent too large (maximum is 1000)' if exp.is_a?(Numeric) && exp > 1000
|
|
135
|
+
base **= exp
|
|
136
|
+
end
|
|
137
|
+
base
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def parse_unary(tokens, pos)
|
|
141
|
+
if pos[0] < tokens.length && tokens[pos[0]] == '-'
|
|
142
|
+
pos[0] += 1
|
|
143
|
+
-parse_atom(tokens, pos)
|
|
144
|
+
elsif pos[0] < tokens.length && tokens[pos[0]] == '+'
|
|
145
|
+
pos[0] += 1
|
|
146
|
+
parse_atom(tokens, pos)
|
|
147
|
+
else
|
|
148
|
+
parse_atom(tokens, pos)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def parse_atom(tokens, pos)
|
|
153
|
+
raise 'Unexpected end of expression' if pos[0] >= tokens.length
|
|
154
|
+
|
|
155
|
+
tok = tokens[pos[0]]
|
|
156
|
+
if tok == '('
|
|
157
|
+
pos[0] += 1
|
|
158
|
+
val = parse_expr(tokens, pos)
|
|
159
|
+
raise 'Missing closing parenthesis' unless pos[0] < tokens.length && tokens[pos[0]] == ')'
|
|
160
|
+
pos[0] += 1
|
|
161
|
+
val
|
|
162
|
+
elsif tok =~ /\A-?[\d.]+\z/
|
|
163
|
+
pos[0] += 1
|
|
164
|
+
tok.include?('.') ? tok.to_f : tok.to_i
|
|
165
|
+
else
|
|
166
|
+
raise "Unexpected token: #{tok}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
SignalWire::Skills::SkillRegistry.register('math') do |params|
|
|
175
|
+
SignalWire::Skills::Builtin::MathSkill.new(params)
|
|
176
|
+
end
|