switest 0.5.1 → 0.7.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.
@@ -1,213 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
- require_relative "from_parser"
5
-
6
- module Switest
7
- module ESL
8
- class Client
9
- attr_reader :connection, :calls
10
-
11
- def initialize(connection = nil)
12
- @connection = connection
13
- @calls = Concurrent::Map.new
14
- @offer_handlers = []
15
- @mutex = Mutex.new
16
- end
17
-
18
- def start
19
- @connection ||= Connection.new(
20
- host: Switest.configuration.host,
21
- port: Switest.configuration.port,
22
- password: Switest.configuration.password
23
- )
24
- @connection.connect
25
-
26
- # Register event handler with connection
27
- @connection.on_event { |response| handle_response(response) }
28
-
29
- self
30
- end
31
-
32
- def stop
33
- hangup_all
34
- @connection&.disconnect
35
-
36
- # Any remaining calls are orphaned - just clear them
37
- @calls.clear
38
- end
39
-
40
- # Hangup all active calls individually and wait for them to end.
41
- # This ensures proper hangup_cause is set for each call (important for CDRs).
42
- # Sends all hangups first, then waits with a shared deadline to avoid
43
- # O(n * timeout) delays with many calls.
44
- def hangup_all(cause: "NORMAL_CLEARING", timeout: 5)
45
- return unless @connection&.connected?
46
-
47
- active = @calls.values.reject(&:ended?)
48
-
49
- # Send all hangups without waiting
50
- active.each do |call|
51
- call.hangup(cause, wait: false) rescue nil
52
- end
53
-
54
- # Wait for all to end with a single shared deadline
55
- deadline = Time.now + timeout
56
- active.each do |call|
57
- remaining = deadline - Time.now
58
- break if remaining <= 0
59
- call.wait_for_end(timeout: remaining) unless call.ended?
60
- end
61
- end
62
-
63
- def dial(to:, from: nil, timeout: nil, headers: {})
64
- uuid = SecureRandom.uuid
65
-
66
- # Build channel variables
67
- vars = {
68
- origination_uuid: uuid,
69
- return_ring_ready: true
70
- }
71
- vars.merge!(FromParser.parse(from)) if from
72
- vars[:originate_timeout] = timeout if timeout
73
-
74
- var_string = Escaper.build_var_string(vars, headers)
75
-
76
- # Create call object before originate
77
- call = Call.new(
78
- id: uuid,
79
- connection: @connection,
80
- direction: :outbound,
81
- to: to,
82
- from: from,
83
- headers: headers
84
- )
85
- @calls[uuid] = call
86
-
87
- # Originate call (park it so we control it)
88
- begin
89
- @connection.bgapi("originate #{var_string}#{to} &park")
90
- rescue
91
- @calls.delete(uuid)
92
- raise
93
- end
94
-
95
- call
96
- end
97
-
98
- def on_offer(&block)
99
- @mutex.synchronize { @offer_handlers << block }
100
- end
101
-
102
- def active_calls
103
- result = {}
104
- @calls.each_pair { |k, v| result[k] = v if v.alive? }
105
- result
106
- end
107
-
108
- private
109
-
110
- def handle_response(response)
111
- return unless response && response[:body]
112
-
113
- event = Event.parse(response[:body])
114
- return unless event
115
-
116
- handle_event(event)
117
- end
118
-
119
- def handle_event(event)
120
- case event.name
121
- when "CHANNEL_CREATE"
122
- handle_channel_create(event)
123
- when "CHANNEL_ANSWER"
124
- handle_channel_answer(event)
125
- when "CHANNEL_BRIDGE"
126
- handle_channel_bridge(event)
127
- when "CHANNEL_HANGUP_COMPLETE"
128
- handle_channel_hangup(event)
129
- when "DTMF"
130
- handle_dtmf(event)
131
- end
132
- end
133
-
134
- def handle_channel_create(event)
135
- uuid = event.uuid
136
- return unless uuid
137
-
138
- # Skip if we already have this call (outbound call we created)
139
- return if @calls[uuid]
140
-
141
- # Only handle inbound calls
142
- direction = event.call_direction
143
- return unless direction == "inbound"
144
-
145
- call = Call.new(
146
- id: uuid,
147
- connection: @connection,
148
- direction: :inbound,
149
- to: event.destination,
150
- from: event.caller_id,
151
- headers: event.headers.dup
152
- )
153
- @calls[uuid] = call
154
-
155
- # Notify offer handlers
156
- fire_offer(call)
157
- end
158
-
159
- def handle_channel_answer(event)
160
- uuid = event.uuid
161
- return unless uuid
162
-
163
- call = @calls[uuid]
164
- other_uuid = event["Other-Leg-Unique-ID"]
165
- # For loopback calls, also check the other leg's UUID
166
- call ||= @calls[other_uuid] if other_uuid
167
- call&.handle_answer
168
- end
169
-
170
- def handle_channel_bridge(event)
171
- uuid = event.uuid
172
- return unless uuid
173
-
174
- call = @calls[uuid]
175
- other_uuid = event["Other-Leg-Unique-ID"]
176
- call ||= @calls[other_uuid] if other_uuid
177
- call&.handle_bridge
178
- end
179
-
180
- def handle_channel_hangup(event)
181
- uuid = event.uuid
182
- return unless uuid
183
-
184
- call = @calls[uuid]
185
- other_uuid = event["Other-Leg-Unique-ID"]
186
- # For loopback calls, also check the other leg's UUID
187
- call ||= @calls[other_uuid] if other_uuid
188
- return unless call
189
-
190
- call.handle_hangup(event.hangup_cause, event.headers)
191
- end
192
-
193
- def handle_dtmf(event)
194
- uuid = event.uuid
195
- return unless uuid
196
-
197
- call = @calls[uuid]
198
- other_uuid = event["Other-Leg-Unique-ID"]
199
- # For loopback calls, also check the other leg's UUID
200
- call ||= @calls[other_uuid] if other_uuid
201
- return unless call
202
-
203
- digit = event["DTMF-Digit"]
204
- call.handle_dtmf(digit) if digit
205
- end
206
-
207
- def fire_offer(call)
208
- handlers = @mutex.synchronize { @offer_handlers.dup }
209
- handlers.each { |h| h.call(call) }
210
- end
211
- end
212
- end
213
- end
@@ -1,226 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "socket"
4
- require "concurrent"
5
-
6
- module Switest
7
- module ESL
8
- # Thread-safe ESL connection.
9
- #
10
- # All socket I/O is handled by a single reader thread to avoid race conditions.
11
- # Commands are sent via a queue and responses are returned via promises.
12
- class Connection
13
- attr_reader :host, :port
14
-
15
- def initialize(host:, port:, password:)
16
- @host = host
17
- @port = port
18
- @password = password
19
- @socket = nil
20
- @running = false
21
- @reader_thread = nil
22
- @command_queue = Queue.new
23
- @event_handlers = []
24
- @mutex = Mutex.new
25
- end
26
-
27
- def connect
28
- @socket = TCPSocket.new(@host, @port)
29
- authenticate
30
- subscribe_events
31
- @running = true
32
- @reader_thread = Thread.new { reader_loop }
33
- self
34
- end
35
-
36
- def disconnect
37
- @running = false
38
- # Close socket to unblock any pending reads
39
- @mutex.synchronize do
40
- if @socket
41
- @socket.close rescue nil
42
- @socket = nil
43
- end
44
- end
45
- # Fail any pending commands
46
- until @command_queue.empty?
47
- item = @command_queue.pop(true) rescue nil
48
- item[:promise].fail(ConnectionError.new("Disconnected")) if item&.dig(:promise)
49
- end
50
- @reader_thread&.join(2)
51
- @reader_thread = nil
52
- end
53
-
54
- def connected?
55
- @running && @socket && !@socket.closed?
56
- end
57
-
58
- # Send a command and wait for response (thread-safe)
59
- def send_command(cmd, timeout: 5)
60
- raise ConnectionError, "Not connected" unless connected?
61
-
62
- promise = Concurrent::IVar.new
63
- @command_queue.push({ cmd: cmd, promise: promise })
64
-
65
- # Wait for response with timeout
66
- result = promise.value(timeout)
67
- if promise.pending?
68
- raise Switest::Error, "Command timed out: #{cmd}"
69
- elsif promise.rejected?
70
- raise promise.reason
71
- end
72
- result
73
- end
74
-
75
- # Register a handler for incoming events
76
- def on_event(&block)
77
- @mutex.synchronize { @event_handlers << block }
78
- end
79
-
80
- def api(cmd)
81
- response = send_command("api #{cmd}")
82
- body = response[:body] || ""
83
- raise Switest::Error, body if body.start_with?("-ERR")
84
- body
85
- end
86
-
87
- def bgapi(cmd)
88
- send_command("bgapi #{cmd}")
89
- end
90
-
91
- private
92
-
93
- def authenticate
94
- # Read auth request (before reader thread starts)
95
- response = read_response
96
- unless response[:headers]["Content-Type"] == "auth/request"
97
- raise AuthenticationError, "Expected auth/request, got: #{response[:headers]["Content-Type"]}"
98
- end
99
-
100
- # Send password
101
- @socket.write("auth #{@password}\n\n")
102
-
103
- # Read reply
104
- response = read_response
105
- reply = response[:headers]["Reply-Text"] || ""
106
- unless reply.start_with?("+OK")
107
- raise AuthenticationError, "Authentication failed: #{reply}"
108
- end
109
- end
110
-
111
- def subscribe_events
112
- events = %w[
113
- CHANNEL_CREATE # Detect new inbound calls
114
- CHANNEL_ANSWER # Track when calls are answered
115
- CHANNEL_BRIDGE # Track when calls are bridged
116
- CHANNEL_HANGUP_COMPLETE # Track call end with final headers
117
- DTMF # Receive DTMF digits per call
118
- ].join(" ")
119
-
120
- @socket.write("event plain #{events}\n\n")
121
- response = read_response
122
- reply = response[:headers]["Reply-Text"] || ""
123
- unless reply.start_with?("+OK")
124
- raise Switest::Error, "Failed to subscribe to events: #{reply}"
125
- end
126
- end
127
-
128
- # Main reader loop - handles both commands and events
129
- def reader_loop
130
- while @running
131
- begin
132
- # Process any pending commands first
133
- process_pending_commands
134
-
135
- # Check for incoming data with short timeout (allows checking queue regularly)
136
- ready = IO.select([@socket], nil, nil, 0.1) rescue nil
137
- break unless @running && @socket && !@socket.closed?
138
- next unless ready
139
-
140
- # Read and dispatch event (skip orphaned command replies)
141
- response = read_response
142
- content_type = response[:headers]["Content-Type"]
143
- next if content_type == "command/reply" || content_type == "api/response"
144
-
145
- if content_type == "text/disconnect-notice"
146
- break
147
- end
148
-
149
- dispatch_event(response)
150
- rescue IOError, Errno::EBADF, Errno::ECONNRESET
151
- # Socket closed
152
- break
153
- rescue
154
- # Log but continue on other errors
155
- break unless @running
156
- end
157
- end
158
- end
159
-
160
- def process_pending_commands
161
- while @running && !@command_queue.empty?
162
- item = @command_queue.pop(true) rescue nil
163
- break unless item
164
-
165
- begin
166
- @socket.write("#{item[:cmd]}\n\n")
167
- response = read_command_response
168
- item[:promise].set(response)
169
- rescue => e
170
- item[:promise].fail(e)
171
- end
172
- end
173
- end
174
-
175
- def dispatch_event(response)
176
- handlers = @mutex.synchronize { @event_handlers.dup }
177
- handlers.each { |h| h.call(response) }
178
- end
179
-
180
- # Read responses until we get a command reply, dispatching any
181
- # interleaved events that arrive before the reply.
182
- def read_command_response
183
- loop do
184
- response = read_response
185
- content_type = response[:headers]["Content-Type"]
186
-
187
- case content_type
188
- when "command/reply", "api/response"
189
- return response
190
- when "text/event-plain"
191
- dispatch_event(response)
192
- when "text/disconnect-notice"
193
- raise ConnectionError, "Disconnected"
194
- else
195
- return response
196
- end
197
- end
198
- end
199
-
200
- def read_response
201
- headers = read_headers
202
- body = nil
203
-
204
- if (content_length = headers["Content-Length"]&.to_i) && content_length > 0
205
- body = @socket.read(content_length)
206
- end
207
-
208
- { headers: headers, body: body }
209
- end
210
-
211
- def read_headers
212
- headers = {}
213
- loop do
214
- line = @socket.gets
215
- raise ConnectionError, "Connection closed" if line.nil?
216
- line = line.chomp
217
- break if line.empty?
218
-
219
- key, value = line.split(": ", 2)
220
- headers[key] = value if key && value
221
- end
222
- headers
223
- end
224
- end
225
- end
226
- end
@@ -1,69 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "uri"
4
-
5
- module Switest
6
- module ESL
7
- class Event
8
- attr_reader :headers
9
-
10
- def self.parse(raw_data)
11
- return nil if raw_data.nil? || raw_data.empty?
12
- new(raw_data)
13
- end
14
-
15
- def initialize(raw_data)
16
- @headers = Switest::CaseInsensitiveHash.new
17
- parse_headers(raw_data)
18
- end
19
-
20
- # Common accessors
21
- def name
22
- @headers["Event-Name"]
23
- end
24
-
25
- def uuid
26
- @headers["Unique-ID"] || @headers["Channel-Call-UUID"]
27
- end
28
-
29
- def caller_id
30
- @headers["Caller-Caller-ID-Number"]
31
- end
32
-
33
- def destination
34
- @headers["Caller-Destination-Number"]
35
- end
36
-
37
- def call_direction
38
- @headers["Call-Direction"]
39
- end
40
-
41
- def hangup_cause
42
- @headers["Hangup-Cause"]
43
- end
44
-
45
- def [](key)
46
- @headers[key]
47
- end
48
-
49
- def variable(name)
50
- @headers["variable_#{name}"]
51
- end
52
-
53
- private
54
-
55
- def parse_headers(raw_data)
56
- raw_data.each_line do |line|
57
- line = line.strip
58
- next if line.empty?
59
-
60
- key, value = line.split(": ", 2)
61
- next unless key && value
62
-
63
- # ESL uses percent-encoding (%XX) but NOT + for spaces
64
- @headers[key] = value.gsub(/%([0-9A-Fa-f]{2})/) { [$1.to_i(16)].pack("C") }
65
- end
66
- end
67
- end
68
- end
69
- end