Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ source "https://rubygems.org"

gemspec name: "switest"

gem 'librevox', github: 'relatel/librevox', branch: 'promises-and-cleanup'

group :test do
gem "rake"
end
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,14 @@ bob.answer
| Method | Direction | What it does |
|-------------------------------|-------------|---------------------------------------------|
| `wait_for_answer(timeout:)` | Outbound | Passively waits for the remote to answer |
| `answer(wait:)` | Inbound | Actively answers the call |
| `answer` | Inbound | Actively answers the call |

#### wait_for_end vs hangup

| Method | Use case | What it does |
|--------------------------|-----------------------|--------------------------------------|
| `wait_for_end(timeout:)` | Remote hangs up | Passively waits for the call to end |
| `hangup(wait:)` | You hang up | Sends hangup and waits |
| `hangup` | You hang up | Sends hangup via execute_app |

## API Reference

Expand All @@ -141,9 +141,9 @@ Agent.listen_for_call(guards) # e.g. to: /pattern/, from: /pattern/
#### Actions

```ruby
agent.answer(wait: 5) # Answer an inbound call
agent.hangup(wait: 5) # Hang up
agent.reject(reason = :decline) # Reject inbound call (:decline or :busy)
agent.answer # Answer an inbound call
agent.hangup # Hang up (uses execute_app)
agent.reject(:decline) # Reject inbound call (:decline or :busy)
agent.play_audio(url) # Play an audio file or tone stream
agent.send_dtmf(digits) # Send DTMF tones
agent.receive_dtmf(count:, timeout:) # Receive DTMF digits
Expand Down
8 changes: 4 additions & 4 deletions lib/switest/agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,14 @@ def call?
!@call.nil?
end

def answer(wait: 5)
def answer
raise "No call to answer" unless @call
@call.answer(wait: wait)
@call.answer
end

def hangup(wait: 5)
def hangup(cause = "NORMAL_CLEARING")
raise "No call to hangup" unless @call
@call.hangup(wait: wait)
@call.hangup(cause)
end

def reject(reason = :decline)
Expand Down
34 changes: 10 additions & 24 deletions lib/switest/call.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,36 +58,32 @@ def outbound?
end

# Actions
def answer(wait: 5)
def answer
return unless @state == :ringing && inbound?
sendmsg("execute", "answer")
return unless wait
wait_for_answer(timeout: wait)
@session.execute_app("answer", @id)
end

def hangup(cause = "NORMAL_CLEARING", wait: 5)
def hangup(cause = "NORMAL_CLEARING")
return if ended?
sendmsg("hangup", hangup_cause: cause)
return unless wait
wait_for_end(timeout: wait)
@session.execute_app("hangup", @id, cause)
end

def reject(reason = :decline, wait: 5)
def reject(reason = :decline)
return unless @state == :ringing && inbound?
cause = case reason
when :busy then "USER_BUSY"
when :decline then "CALL_REJECTED"
else "CALL_REJECTED"
end
hangup(cause, wait: wait)
hangup(cause)
end

def play_audio(url, wait: true)
sendmsg("execute", "playback", url, event_lock: wait)
def play_audio(url)
@session.execute_app("playback", @id, url)
end

def send_dtmf(digits, wait: true)
play_audio("tone_stream://d=200;w=250;#{digits}", wait: wait)
def send_dtmf(digits)
play_audio("tone_stream://d=200;w=250;#{digits}")
end

def flush_dtmf
Expand Down Expand Up @@ -182,15 +178,5 @@ def handle_dtmf(digit)

private

def sendmsg(command, app = nil, arg = nil, event_lock: false, hangup_cause: nil)
msg = +"sendmsg #{@id}\n"
msg << "call-command: #{command}\n"
msg << "execute-app-name: #{app}\n" if app
msg << "execute-app-arg: #{arg}\n" if arg
msg << "event-lock: true\n" if event_lock
msg << "hangup-cause: #{hangup_cause}" if hangup_cause
@session.send_message(msg.chomp)
end

end
end
32 changes: 18 additions & 14 deletions lib/switest/client.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# frozen_string_literal: true

require "securerandom"
require "async/barrier"
require "async/promise"
require "io/endpoint/host_endpoint"
require_relative "from_parser"

module Switest
Expand All @@ -24,15 +24,17 @@ def start
Session.offer_handler = method(:handle_inbound_offer)
Session.connection_promise = Async::Promise.new

endpoint = IO::Endpoint.tcp(config.host, config.port)
client = Librevox::Client.new(Session, endpoint, auth: config.password)
@client_task = Async { client.run }
@client_task = Async do
Librevox::Client.start(Session, host: config.host, port: config.port, auth: config.password)
end

# Wait for the session to be established
@session = Async::Task.current.with_timeout(config.default_timeout) do
session = Async::Task.current.with_timeout(config.default_timeout) do
Session.connection_promise.wait
end

@session = session

self
rescue Async::TimeoutError
@client_task&.stop
Expand All @@ -58,16 +60,18 @@ def connected?
def hangup_all(cause: "NORMAL_CLEARING", timeout: 5)
active = @calls.values.reject(&:ended?)

active.each do |call|
call.hangup(cause, wait: false) rescue nil
end

deadline = Time.now + timeout
active.each do |call|
remaining = deadline - Time.now
break if remaining <= 0
call.wait_for_end(timeout: remaining) unless call.ended?
Async::Task.current.with_timeout(timeout) do
barrier = Async::Barrier.new
active.each do |call|
barrier.async do
call.hangup(cause) rescue nil
call.wait_for_end(timeout: timeout) unless call.ended?
end
end
barrier.wait
end
rescue Async::TimeoutError
# Best-effort cleanup; don't block teardown
end

def dial(to:, from: nil, timeout: nil, headers: {})
Expand Down
22 changes: 11 additions & 11 deletions lib/switest/escaper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@

module Switest
# Escapes values for use in FreeSWITCH channel variable strings.
#
# FreeSWITCH originate syntax: {var1=value1,var2=value2}endpoint
#
# Escaping rules (per FreeSWITCH documentation):
# - Spaces: wrap value in single quotes
# - Commas in regular vars: use ^^<delim> syntax (e.g., ^^:val1:val2)
# - Commas in SIP headers (sip_h_*): escape with backslash (\,)
# - Single quotes in quoted values: escape with backslash (\')
#
# @see https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Dialplan/Channel-Variables_16352493/
module Escaper
#
# FreeSWITCH originate syntax: {var1=value1,var2=value2}endpoint
#
# Escaping rules (per FreeSWITCH documentation):
# - Spaces: wrap value in single quotes
# - Commas in regular vars: use ^^<delim> syntax (e.g., ^^:val1:val2)
# - Commas in SIP headers (sip_h_*): escape with backslash (\,)
# - Single quotes in quoted values: escape with backslash (\')
#
# @see https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Dialplan/Channel-Variables_16352493/
module Escaper
module_function

# Characters that require the value to be quoted
Expand Down
20 changes: 10 additions & 10 deletions lib/switest/from_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@

module Switest
# Parses a `from` string into FreeSWITCH channel variables,
# replicating mod_rayo's parse_dial_from() behavior.
#
# Algorithm (matching mod_rayo):
# 1. Split on last space — before = display name, after = URI
# 2. No space — entire string is URI, no display name
# 3. Strip "" from display name (if quoted)
# 4. Strip <> from URI (if angle-bracketed)
# 5. Detect scheme: sip:/sips: with @ → SIP, tel: → TEL, plain → TEL, empty → UNKNOWN
# 6. Map to FreeSWITCH variables accordingly
module FromParser
# replicating mod_rayo's parse_dial_from() behavior.
#
# Algorithm (matching mod_rayo):
# 1. Split on last space — before = display name, after = URI
# 2. No space — entire string is URI, no display name
# 3. Strip "" from display name (if quoted)
# 4. Strip <> from URI (if angle-bracketed)
# 5. Detect scheme: sip:/sips: with @ → SIP, tel: → TEL, plain → TEL, empty → UNKNOWN
# 6. Map to FreeSWITCH variables accordingly
module FromParser
module_function

SIP_URI_PATTERN = /\Asips?:.+@/
Expand Down
13 changes: 13 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ def send_message(msg)
mock_response
end

def execute_app(app, uuid, args = nil, **params)
headers = {
event_lock: true,
call_command: "execute",
execute_app_name: app,
execute_app_arg: args,
}.merge(params)
.map { |key, value| "#{key.to_s.tr('_', '-')}: #{value}" }

@commands_sent << "sendmsg #{uuid}\n#{headers.join("\n")}"
mock_response
end

def bgapi(cmd)
send_message("bgapi #{cmd}")
end
Expand Down
6 changes: 3 additions & 3 deletions test/unit/switest/agent_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def test_answer_sends_command
call.handle_callstate("RINGING")
agent = Switest::Agent.new(call)

agent.answer(wait: false)
agent.answer

assert @session.commands_sent.any? { |cmd| cmd.include?("execute-app-name: answer") }
end
Expand All @@ -108,8 +108,8 @@ def test_hangup_sends_command
call = make_call
agent = Switest::Agent.new(call)

agent.hangup(wait: false)
agent.hangup

assert @session.commands_sent.any? { |cmd| cmd.include?("call-command: hangup") }
assert @session.commands_sent.any? { |cmd| cmd.include?("execute-app-name: hangup") }
end
end
Loading
Loading