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,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignalWire
4
+ module REST
5
+ module Namespaces
6
+ # Short code management (read + update only).
7
+ class ShortCodesResource < BaseResource
8
+ def initialize(http)
9
+ super(http, '/api/relay/rest/short_codes')
10
+ end
11
+
12
+ def list(**params)
13
+ @http.get(@base_path, params.empty? ? nil : params)
14
+ end
15
+
16
+ def get(short_code_id)
17
+ @http.get(_path(short_code_id))
18
+ end
19
+
20
+ def update(short_code_id, **kwargs)
21
+ @http.put(_path(short_code_id), kwargs)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignalWire
4
+ module REST
5
+ module Namespaces
6
+ # Project SIP profile (singleton resource).
7
+ class SipProfileResource < BaseResource
8
+ def initialize(http)
9
+ super(http, '/api/relay/rest/sip_profile')
10
+ end
11
+
12
+ def get
13
+ @http.get(@base_path)
14
+ end
15
+
16
+ def update(**kwargs)
17
+ @http.put(@base_path, kwargs)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignalWire
4
+ module REST
5
+ module Namespaces
6
+ # Verified caller ID management with verification flow.
7
+ class VerifiedCallersResource < CrudResource
8
+ self.update_method = 'PUT'
9
+
10
+ def initialize(http)
11
+ super(http, '/api/relay/rest/verified_caller_ids')
12
+ end
13
+
14
+ def redial_verification(caller_id)
15
+ @http.post(_path(caller_id, 'verification'))
16
+ end
17
+
18
+ def submit_verification(caller_id, **kwargs)
19
+ @http.put(_path(caller_id, 'verification'), kwargs)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignalWire
4
+ module REST
5
+ module Namespaces
6
+ # Video room management with streams.
7
+ class VideoRooms < CrudResource
8
+ self.update_method = 'PUT'
9
+
10
+ def list_streams(room_id, **params)
11
+ @http.get(_path(room_id, 'streams'), params.empty? ? nil : params)
12
+ end
13
+
14
+ def create_stream(room_id, **kwargs)
15
+ @http.post(_path(room_id, 'streams'), kwargs)
16
+ end
17
+ end
18
+
19
+ # Video room token generation.
20
+ class VideoRoomTokens < BaseResource
21
+ def create(**kwargs)
22
+ @http.post(@base_path, kwargs)
23
+ end
24
+ end
25
+
26
+ # Video room session management.
27
+ class VideoRoomSessions < BaseResource
28
+ def list(**params)
29
+ @http.get(@base_path, params.empty? ? nil : params)
30
+ end
31
+
32
+ def get(session_id)
33
+ @http.get(_path(session_id))
34
+ end
35
+
36
+ def list_events(session_id, **params)
37
+ @http.get(_path(session_id, 'events'), params.empty? ? nil : params)
38
+ end
39
+
40
+ def list_members(session_id, **params)
41
+ @http.get(_path(session_id, 'members'), params.empty? ? nil : params)
42
+ end
43
+
44
+ def list_recordings(session_id, **params)
45
+ @http.get(_path(session_id, 'recordings'), params.empty? ? nil : params)
46
+ end
47
+ end
48
+
49
+ # Video room recording management.
50
+ class VideoRoomRecordings < BaseResource
51
+ def list(**params)
52
+ @http.get(@base_path, params.empty? ? nil : params)
53
+ end
54
+
55
+ def get(recording_id)
56
+ @http.get(_path(recording_id))
57
+ end
58
+
59
+ def delete(recording_id)
60
+ @http.delete(_path(recording_id))
61
+ end
62
+
63
+ def list_events(recording_id, **params)
64
+ @http.get(_path(recording_id, 'events'), params.empty? ? nil : params)
65
+ end
66
+ end
67
+
68
+ # Video conference management with tokens and streams.
69
+ class VideoConferences < CrudResource
70
+ self.update_method = 'PUT'
71
+
72
+ def list_conference_tokens(conference_id, **params)
73
+ @http.get(_path(conference_id, 'conference_tokens'), params.empty? ? nil : params)
74
+ end
75
+
76
+ def list_streams(conference_id, **params)
77
+ @http.get(_path(conference_id, 'streams'), params.empty? ? nil : params)
78
+ end
79
+
80
+ def create_stream(conference_id, **kwargs)
81
+ @http.post(_path(conference_id, 'streams'), kwargs)
82
+ end
83
+ end
84
+
85
+ # Video conference token management.
86
+ class VideoConferenceTokens < BaseResource
87
+ def get(token_id)
88
+ @http.get(_path(token_id))
89
+ end
90
+
91
+ def reset(token_id)
92
+ @http.post(_path(token_id, 'reset'))
93
+ end
94
+ end
95
+
96
+ # Video stream management.
97
+ class VideoStreams < BaseResource
98
+ def get(stream_id)
99
+ @http.get(_path(stream_id))
100
+ end
101
+
102
+ def update(stream_id, **kwargs)
103
+ @http.put(_path(stream_id), kwargs)
104
+ end
105
+
106
+ def delete(stream_id)
107
+ @http.delete(_path(stream_id))
108
+ end
109
+ end
110
+
111
+ # Video API namespace.
112
+ class VideoNamespace
113
+ attr_reader :rooms, :room_tokens, :room_sessions, :room_recordings,
114
+ :conferences, :conference_tokens, :streams
115
+
116
+ def initialize(http)
117
+ base = '/api/video'
118
+ @rooms = VideoRooms.new(http, "#{base}/rooms")
119
+ @room_tokens = VideoRoomTokens.new(http, "#{base}/room_tokens")
120
+ @room_sessions = VideoRoomSessions.new(http, "#{base}/room_sessions")
121
+ @room_recordings = VideoRoomRecordings.new(http, "#{base}/room_recordings")
122
+ @conferences = VideoConferences.new(http, "#{base}/conferences")
123
+ @conference_tokens = VideoConferenceTokens.new(http, "#{base}/conference_tokens")
124
+ @streams = VideoStreams.new(http, "#{base}/streams")
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignalWire
4
+ module REST
5
+ # Iterates items across paginated API responses.
6
+ #
7
+ # Mirrors the Python PaginatedIterator (signalwire.rest._pagination):
8
+ # the constructor records http/path/params/data_key without fetching;
9
+ # iteration walks pages by following the +links.next+ cursor.
10
+ #
11
+ # Usage:
12
+ # iter = SignalWire::REST::PaginatedIterator.new(http, '/api/path',
13
+ # params: {}, data_key: 'data')
14
+ # iter.each { |item| ... }
15
+ #
16
+ # The iterator is single-pass (matching Python's __next__ semantics);
17
+ # use #to_a to collect every item across all pages.
18
+ class PaginatedIterator
19
+ include Enumerable
20
+
21
+ attr_reader :http, :path, :params, :data_key, :index, :items, :done
22
+
23
+ def initialize(http, path, params = nil, data_key = 'data')
24
+ @http = http
25
+ @path = path
26
+ # Dup so callers can't mutate iterator state via the original Hash.
27
+ @params = params ? params.dup : {}
28
+ @data_key = data_key
29
+ @items = []
30
+ @index = 0
31
+ @done = false
32
+ end
33
+
34
+ def each
35
+ return enum_for(:each) unless block_given?
36
+
37
+ loop do
38
+ item = next_item
39
+ break if item == :__stop__
40
+
41
+ yield item
42
+ end
43
+ end
44
+
45
+ # Equivalent of Python's __next__. Returns the sentinel +:__stop__+
46
+ # when exhausted (Ruby has no StopIteration error idiom for plain
47
+ # Enumerable), but the public surface is +#each+.
48
+ def next_item
49
+ while @index >= @items.length
50
+ return :__stop__ if @done
51
+
52
+ fetch_next
53
+ end
54
+ item = @items[@index]
55
+ @index += 1
56
+ item
57
+ end
58
+
59
+ private
60
+
61
+ def fetch_next
62
+ params_for_request = @params.empty? ? nil : @params
63
+ resp = @http.get(@path, params_for_request)
64
+ data = resp[@data_key] || []
65
+ @items.concat(data)
66
+
67
+ links = resp['links'] || {}
68
+ next_url = links['next']
69
+ if next_url && !data.empty?
70
+ uri = URI.parse(next_url)
71
+ query = URI.decode_www_form(uri.query || '')
72
+ # Flatten single-value lists, preserving multi-value entries.
73
+ flat = {}
74
+ query.each do |k, v|
75
+ if flat.key?(k)
76
+ existing = flat[k]
77
+ flat[k] = existing.is_a?(Array) ? existing + [v] : [existing, v]
78
+ else
79
+ flat[k] = v
80
+ end
81
+ end
82
+ @params = flat
83
+ else
84
+ @done = true
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignalWire
4
+ module REST
5
+ # PhoneCallHandler -- enum-like constants of the +call_handler+ values
6
+ # accepted by +phone_numbers.update+.
7
+ #
8
+ # Named +PhoneCallHandler+ (not +CallHandler+) to avoid colliding with
9
+ # the RELAY client's inbound-call-handler callback (+Relay::Client#on_call+).
10
+ #
11
+ # Setting a phone number's +call_handler+ together with the handler-specific
12
+ # companion field routes inbound calls and auto-materializes the matching
13
+ # Fabric resource on the server. See the high-level helpers on
14
+ # +SignalWire::REST::Namespaces::PhoneNumbersResource+.
15
+ #
16
+ # === Mapping
17
+ #
18
+ # Constant | Companion field (required) | Auto-creates resource
19
+ # ------------------+--------------------------------+----------------------
20
+ # RELAY_SCRIPT | call_relay_script_url | swml_webhook
21
+ # LAML_WEBHOOKS | call_request_url | cxml_webhook
22
+ # LAML_APPLICATION | call_laml_application_id | cxml_application
23
+ # AI_AGENT | call_ai_agent_id | ai_agent
24
+ # CALL_FLOW | call_flow_id | call_flow
25
+ # RELAY_APPLICATION | call_relay_application | relay_application
26
+ # RELAY_TOPIC | call_relay_topic | (routes via RELAY)
27
+ # RELAY_CONTEXT | call_relay_context | (legacy, prefer topic)
28
+ # RELAY_CONNECTOR | (connector config) | (internal)
29
+ # VIDEO_ROOM | call_video_room_id | (routes to Video API)
30
+ # DIALOGFLOW | call_dialogflow_agent_id | (none)
31
+ #
32
+ # Note: +LAML_WEBHOOKS+ (wire value +laml_webhooks+) produces a *cXML*
33
+ # handler despite the plural name, not a generic webhook. For SWML, use
34
+ # +RELAY_SCRIPT+.
35
+ module PhoneCallHandler
36
+ RELAY_SCRIPT = 'relay_script'
37
+ LAML_WEBHOOKS = 'laml_webhooks'
38
+ LAML_APPLICATION = 'laml_application'
39
+ AI_AGENT = 'ai_agent'
40
+ CALL_FLOW = 'call_flow'
41
+ RELAY_APPLICATION = 'relay_application'
42
+ RELAY_TOPIC = 'relay_topic'
43
+ RELAY_CONTEXT = 'relay_context'
44
+ RELAY_CONNECTOR = 'relay_connector'
45
+ VIDEO_ROOM = 'video_room'
46
+ DIALOGFLOW = 'dialogflow'
47
+
48
+ # All supported wire values, in the same order as the constants.
49
+ ALL = [
50
+ RELAY_SCRIPT, LAML_WEBHOOKS, LAML_APPLICATION, AI_AGENT, CALL_FLOW,
51
+ RELAY_APPLICATION, RELAY_TOPIC, RELAY_CONTEXT, RELAY_CONNECTOR,
52
+ VIDEO_ROOM, DIALOGFLOW
53
+ ].freeze
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'http_client'
4
+ require_relative 'pagination'
5
+ require_relative 'phone_call_handler'
6
+ require_relative 'namespaces/fabric'
7
+ require_relative 'namespaces/calling'
8
+ require_relative 'namespaces/phone_numbers'
9
+ require_relative 'namespaces/addresses'
10
+ require_relative 'namespaces/queues'
11
+ require_relative 'namespaces/recordings'
12
+ require_relative 'namespaces/number_groups'
13
+ require_relative 'namespaces/verified_callers'
14
+ require_relative 'namespaces/sip_profile'
15
+ require_relative 'namespaces/lookup'
16
+ require_relative 'namespaces/short_codes'
17
+ require_relative 'namespaces/imported_numbers'
18
+ require_relative 'namespaces/mfa'
19
+ require_relative 'namespaces/registry'
20
+ require_relative 'namespaces/datasphere'
21
+ require_relative 'namespaces/video'
22
+ require_relative 'namespaces/logs'
23
+ require_relative 'namespaces/project'
24
+ require_relative 'namespaces/pubsub'
25
+ require_relative 'namespaces/chat'
26
+ require_relative 'namespaces/compat'
27
+
28
+ module SignalWire
29
+ module REST
30
+ # REST client for the SignalWire platform APIs.
31
+ #
32
+ # Usage:
33
+ # client = SignalWire::REST::RestClient.new(
34
+ # project: 'your-project-id',
35
+ # token: 'your-api-token',
36
+ # host: 'your-space.signalwire.com'
37
+ # )
38
+ #
39
+ # # Or use environment variables:
40
+ # # SIGNALWIRE_PROJECT_ID, SIGNALWIRE_API_TOKEN, SIGNALWIRE_SPACE
41
+ # client = SignalWire::REST::RestClient.new
42
+ #
43
+ # # Use namespaced resources
44
+ # client.fabric.ai_agents.list
45
+ # client.calling.play(call_id, play: [...])
46
+ # client.phone_numbers.search(area_code: '512')
47
+ # client.video.rooms.create(name: 'standup')
48
+ # client.compat.calls.list
49
+ class RestClient
50
+ attr_reader :fabric, :calling, :phone_numbers, :datasphere, :video,
51
+ :compat, :addresses, :queues, :recordings, :number_groups,
52
+ :verified_callers, :sip_profile, :lookup, :short_codes,
53
+ :imported_numbers, :mfa, :registry, :logs, :project,
54
+ :pubsub, :chat, :project_id, :http
55
+
56
+ # +base_url+ overrides the derived +https://{space}+ default. The
57
+ # audit harness uses this to point at the local fixture server.
58
+ def initialize(project: nil, token: nil, host: nil, base_url: nil)
59
+ project_id = project || ENV['SIGNALWIRE_PROJECT_ID'] || ''
60
+ api_token = token || ENV['SIGNALWIRE_API_TOKEN'] || ''
61
+ space = host || ENV['SIGNALWIRE_SPACE'] || ''
62
+
63
+ if project_id.empty? || api_token.empty? || (space.empty? && (base_url.nil? || base_url.empty?))
64
+ raise ArgumentError,
65
+ 'project, token, and host are required. ' \
66
+ 'Provide them as arguments or set SIGNALWIRE_PROJECT_ID, ' \
67
+ 'SIGNALWIRE_API_TOKEN, and SIGNALWIRE_SPACE environment variables.'
68
+ end
69
+
70
+ @project_id = project_id
71
+ @http = HttpClient.new(project_id, api_token, space, base_url: base_url)
72
+
73
+ # Fabric API
74
+ @fabric = Namespaces::FabricNamespace.new(@http)
75
+
76
+ # Calling API
77
+ @calling = Namespaces::CallingNamespace.new(@http)
78
+
79
+ # Relay REST resources
80
+ @phone_numbers = Namespaces::PhoneNumbersResource.new(@http)
81
+ @addresses = Namespaces::AddressesResource.new(@http)
82
+ @queues = Namespaces::QueuesResource.new(@http)
83
+ @recordings = Namespaces::RecordingsResource.new(@http)
84
+ @number_groups = Namespaces::NumberGroupsResource.new(@http)
85
+ @verified_callers = Namespaces::VerifiedCallersResource.new(@http)
86
+ @sip_profile = Namespaces::SipProfileResource.new(@http)
87
+ @lookup = Namespaces::LookupResource.new(@http)
88
+ @short_codes = Namespaces::ShortCodesResource.new(@http)
89
+ @imported_numbers = Namespaces::ImportedNumbersResource.new(@http)
90
+ @mfa = Namespaces::MfaResource.new(@http)
91
+ @registry = Namespaces::RegistryNamespace.new(@http)
92
+
93
+ # Datasphere API
94
+ @datasphere = Namespaces::DatasphereNamespace.new(@http)
95
+
96
+ # Video API
97
+ @video = Namespaces::VideoNamespace.new(@http)
98
+
99
+ # Logs
100
+ @logs = Namespaces::LogsNamespace.new(@http)
101
+
102
+ # Project management
103
+ @project = Namespaces::ProjectNamespace.new(@http)
104
+
105
+ # PubSub & Chat
106
+ @pubsub = Namespaces::PubSubResource.new(@http)
107
+ @chat = Namespaces::ChatResource.new(@http)
108
+
109
+ # Compatibility (Twilio-compatible) API
110
+ @compat = Namespaces::CompatNamespace.new(@http, project_id)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,98 @@
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
+ module SignalWire
9
+ # Runtime environment detection.
10
+ #
11
+ # Detects the execution environment (plain server, AWS Lambda, CGI,
12
+ # Google Cloud Functions, Azure Functions) by inspecting well-known
13
+ # environment variables set by each platform.
14
+ #
15
+ # This is the Ruby counterpart to the Python SDK's
16
+ # +signalwire.core.logging_config.get_execution_mode+.
17
+ module Runtime
18
+ MODES = %i[server lambda cgi google_cloud_function azure_function unknown].freeze
19
+
20
+ # Determine the current execution mode.
21
+ #
22
+ # Returns one of:
23
+ # - +:cgi+ - running under a CGI gateway (GATEWAY_INTERFACE is set)
24
+ # - +:lambda+ - running under AWS Lambda
25
+ # - +:google_cloud_function+ - Google Cloud Functions / Cloud Run
26
+ # - +:azure_function+ - Azure Functions
27
+ # - +:server+ - long-running HTTP server (the default)
28
+ #
29
+ # Detection order matters: CGI is checked before Lambda because a
30
+ # Lambda function invoked through an emulator that also sets
31
+ # GATEWAY_INTERFACE should still be treated as CGI.
32
+ #
33
+ # @return [Symbol] one of the values in {MODES}
34
+ def self.execution_mode
35
+ # CGI environment (e.g. Apache mod_cgi)
36
+ return :cgi if ENV['GATEWAY_INTERFACE'] && !ENV['GATEWAY_INTERFACE'].empty?
37
+
38
+ # AWS Lambda
39
+ if (ENV['AWS_LAMBDA_FUNCTION_NAME'] && !ENV['AWS_LAMBDA_FUNCTION_NAME'].empty?) ||
40
+ (ENV['LAMBDA_TASK_ROOT'] && !ENV['LAMBDA_TASK_ROOT'].empty?)
41
+ return :lambda
42
+ end
43
+
44
+ # Google Cloud Functions / Cloud Run
45
+ if (ENV['FUNCTION_TARGET'] && !ENV['FUNCTION_TARGET'].empty?) ||
46
+ (ENV['K_SERVICE'] && !ENV['K_SERVICE'].empty?) ||
47
+ (ENV['GOOGLE_CLOUD_PROJECT'] && !ENV['GOOGLE_CLOUD_PROJECT'].empty?)
48
+ return :google_cloud_function
49
+ end
50
+
51
+ # Azure Functions
52
+ if (ENV['AZURE_FUNCTIONS_ENVIRONMENT'] && !ENV['AZURE_FUNCTIONS_ENVIRONMENT'].empty?) ||
53
+ (ENV['FUNCTIONS_WORKER_RUNTIME'] && !ENV['FUNCTIONS_WORKER_RUNTIME'].empty?) ||
54
+ (ENV['AzureWebJobsStorage'] && !ENV['AzureWebJobsStorage'].empty?)
55
+ return :azure_function
56
+ end
57
+
58
+ :server
59
+ end
60
+
61
+ # True when the SDK is running inside AWS Lambda.
62
+ # @return [Boolean]
63
+ def self.lambda?
64
+ execution_mode == :lambda
65
+ end
66
+
67
+ # True when the SDK is running inside any serverless platform.
68
+ # @return [Boolean]
69
+ def self.serverless?
70
+ mode = execution_mode
71
+ mode == :lambda || mode == :cgi ||
72
+ mode == :google_cloud_function || mode == :azure_function
73
+ end
74
+
75
+ # Construct the base URL for the current Lambda function.
76
+ #
77
+ # Prefers +AWS_LAMBDA_FUNCTION_URL+ when set; otherwise falls back to
78
+ # the standard Function URL shape built from +AWS_LAMBDA_FUNCTION_NAME+
79
+ # and +AWS_REGION+. Returns +nil+ when neither signal is present.
80
+ #
81
+ # The returned URL never has a trailing slash and never contains a
82
+ # path component, so callers must append the agent's route themselves.
83
+ #
84
+ # @return [String, nil]
85
+ def self.lambda_base_url
86
+ explicit = ENV['AWS_LAMBDA_FUNCTION_URL']
87
+ return explicit.chomp('/') if explicit && !explicit.empty?
88
+
89
+ function_name = ENV['AWS_LAMBDA_FUNCTION_NAME']
90
+ return nil if function_name.nil? || function_name.empty?
91
+
92
+ region = ENV['AWS_REGION']
93
+ region = 'us-east-1' if region.nil? || region.empty?
94
+
95
+ "https://#{function_name}.lambda-url.#{region}.on.aws"
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,124 @@
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 'openssl'
9
+ require 'securerandom'
10
+ require 'base64'
11
+
12
+ module SignalWire
13
+ module Security
14
+ # Stateless HMAC-SHA256 session manager for secure SWAIG tool tokens.
15
+ #
16
+ # Tokens are self-contained: all information needed for validation is
17
+ # encoded inside the token itself. No server-side session state is stored.
18
+ #
19
+ # mgr = SessionManager.new(token_expiry_secs: 900)
20
+ # token = mgr.create_token("lookup_order", "call-abc-123")
21
+ # mgr.validate_token("lookup_order", token, "call-abc-123") # => true
22
+ #
23
+ class SessionManager
24
+ # @param token_expiry_secs [Integer] seconds until tokens expire (minimum 1)
25
+ # @param secret_key [String, nil] hex-encoded secret; generated if omitted
26
+ def initialize(token_expiry_secs: 3600, secret_key: nil)
27
+ @token_expiry_secs = [token_expiry_secs, 1].max
28
+ @secret_key = secret_key || SecureRandom.hex(32)
29
+ end
30
+
31
+ # Create a secure, self-contained token for a function call.
32
+ #
33
+ # Token format (before Base64):
34
+ # call_id.function_name.expiry_timestamp.nonce.hmac_hex
35
+ #
36
+ # @param function_name [String]
37
+ # @param call_id [String]
38
+ # @return [String] URL-safe Base64-encoded token
39
+ def create_token(function_name, call_id)
40
+ expiry = (Time.now.to_i + @token_expiry_secs).to_s
41
+ nonce = SecureRandom.hex(8)
42
+
43
+ message = "#{call_id}:#{function_name}:#{expiry}:#{nonce}"
44
+ signature = compute_hmac(message)
45
+
46
+ token_raw = "#{call_id}.#{function_name}.#{expiry}.#{nonce}.#{signature}"
47
+ Base64.urlsafe_encode64(token_raw, padding: false)
48
+ end
49
+
50
+ # Validate a function-call token.
51
+ #
52
+ # Checks:
53
+ # 1. Correct Base64 / structure (5 dot-separated parts)
54
+ # 2. HMAC signature (timing-safe comparison)
55
+ # 3. Function name matches
56
+ # 4. Call ID matches
57
+ # 5. Token not expired
58
+ #
59
+ # @param function_name [String]
60
+ # @param token [String] the token to validate
61
+ # @param call_id [String]
62
+ # @return [Boolean]
63
+ def validate_token(function_name, token, call_id)
64
+ return false if token.nil? || token.empty?
65
+ return false if call_id.nil? || call_id.empty?
66
+
67
+ decoded = Base64.urlsafe_decode64(token)
68
+ parts = decoded.split(".")
69
+ return false unless parts.length == 5
70
+
71
+ token_call_id, token_function, token_expiry, token_nonce, token_signature = parts
72
+
73
+ # Verify function name
74
+ return false unless token_function == function_name
75
+
76
+ # Verify call ID
77
+ return false unless token_call_id == call_id
78
+
79
+ # Check expiry
80
+ expiry = Integer(token_expiry)
81
+ return false if expiry < Time.now.to_i
82
+
83
+ # Recompute HMAC and compare with timing-safe comparison
84
+ message = "#{token_call_id}:#{token_function}:#{token_expiry}:#{token_nonce}"
85
+ expected_signature = compute_hmac(message)
86
+
87
+ secure_compare(token_signature, expected_signature)
88
+ rescue ArgumentError, TypeError
89
+ # Bad Base64, bad integer, etc.
90
+ false
91
+ end
92
+
93
+ private
94
+
95
+ # Compute HMAC-SHA256 of +message+ using the instance secret key.
96
+ # @return [String] hex digest
97
+ def compute_hmac(message)
98
+ OpenSSL::HMAC.hexdigest("SHA256", @secret_key, message)
99
+ end
100
+
101
+ # Timing-safe string comparison.
102
+ #
103
+ # Uses OpenSSL.fixed_length_secure_compare when the strings are the same
104
+ # length (which they should be for hex HMAC digests). Falls back to a
105
+ # double-HMAC comparison for differing lengths.
106
+ def secure_compare(a, b)
107
+ return false if a.nil? || b.nil?
108
+
109
+ if a.bytesize == b.bytesize
110
+ OpenSSL.fixed_length_secure_compare(a, b)
111
+ else
112
+ # Different length => definitely not equal, but still constant-time
113
+ false
114
+ end
115
+ rescue NoMethodError
116
+ # Fallback for older Ruby without fixed_length_secure_compare:
117
+ # compare HMAC of both values so timing doesn't leak content.
118
+ ha = OpenSSL::HMAC.digest("SHA256", @secret_key, a.to_s)
119
+ hb = OpenSSL::HMAC.digest("SHA256", @secret_key, b.to_s)
120
+ ha == hb
121
+ end
122
+ end
123
+ end
124
+ end