diff --git a/Gemfile b/Gemfile index 409ae4f..8d7af94 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/README.md b/README.md index 3aac2f4..27bfb94 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/lib/switest/agent.rb b/lib/switest/agent.rb index 48e248f..ecb054a 100644 --- a/lib/switest/agent.rb +++ b/lib/switest/agent.rb @@ -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) diff --git a/lib/switest/call.rb b/lib/switest/call.rb index 28648c5..e9d0234 100644 --- a/lib/switest/call.rb +++ b/lib/switest/call.rb @@ -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 @@ -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 diff --git a/lib/switest/client.rb b/lib/switest/client.rb index 161ceb2..f64b6f7 100644 --- a/lib/switest/client.rb +++ b/lib/switest/client.rb @@ -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 @@ -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 @@ -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: {}) diff --git a/lib/switest/escaper.rb b/lib/switest/escaper.rb index 5b9d103..368a928 100644 --- a/lib/switest/escaper.rb +++ b/lib/switest/escaper.rb @@ -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 ^^ 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 ^^ 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 diff --git a/lib/switest/from_parser.rb b/lib/switest/from_parser.rb index 3dd5275..d81910d 100644 --- a/lib/switest/from_parser.rb +++ b/lib/switest/from_parser.rb @@ -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?:.+@/ diff --git a/test/test_helper.rb b/test/test_helper.rb index 1c37a45..a7a1aa1 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -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 diff --git a/test/unit/switest/agent_test.rb b/test/unit/switest/agent_test.rb index e961583..9636331 100644 --- a/test/unit/switest/agent_test.rb +++ b/test/unit/switest/agent_test.rb @@ -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 @@ -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 diff --git a/test/unit/switest/call_test.rb b/test/unit/switest/call_test.rb index 5d3c6a8..297a268 100644 --- a/test/unit/switest/call_test.rb +++ b/test/unit/switest/call_test.rb @@ -202,19 +202,19 @@ def test_answer_only_works_for_inbound_ringing inbound.handle_callstate("RINGING") outbound.handle_callstate("RINGING") - inbound.answer(wait: false) - outbound.answer(wait: false) + inbound.answer + outbound.answer # Only inbound should have sent the answer command answer_commands = @session.commands_sent.select { |c| c.include?("execute-app-name: answer") } assert_equal 1, answer_commands.length end - def test_answer_sends_sendmsg_execute + def test_answer_sends_execute_app call = make_call(direction: :inbound) call.handle_callstate("RINGING") - call.answer(wait: false) + call.answer command = @session.commands_sent.last assert_match(/sendmsg test-uuid/, command) @@ -222,48 +222,50 @@ def test_answer_sends_sendmsg_execute assert_match(/execute-app-name: answer/, command) end - def test_hangup_sends_sendmsg_hangup + def test_hangup_sends_execute_app call = make_call(direction: :outbound) - call.hangup("USER_BUSY", wait: false) + call.hangup("USER_BUSY") command = @session.commands_sent.last assert_match(/sendmsg test-uuid/, command) - assert_match(/call-command: hangup/, command) - assert_match(/hangup-cause: USER_BUSY/, command) + assert_match(/call-command: execute/, command) + assert_match(/execute-app-name: hangup/, command) + assert_match(/execute-app-arg: USER_BUSY/, command) end def test_hangup_defaults_to_normal_clearing call = make_call(direction: :outbound) - call.hangup(wait: false) + call.hangup command = @session.commands_sent.last assert_match(/sendmsg test-uuid/, command) - assert_match(/hangup-cause: NORMAL_CLEARING/, command) + assert_match(/execute-app-name: hangup/, command) + assert_match(/execute-app-arg: NORMAL_CLEARING/, command) end def test_reject_busy_uses_user_busy_cause call = make_call(direction: :inbound) call.handle_callstate("RINGING") - call.reject(:busy, wait: false) + call.reject(:busy) command = @session.commands_sent.last - assert_match(/hangup-cause: USER_BUSY/, command) + assert_match(/execute-app-arg: USER_BUSY/, command) end def test_reject_decline_uses_call_rejected_cause call = make_call(direction: :inbound) call.handle_callstate("RINGING") - call.reject(:decline, wait: false) + call.reject(:decline) command = @session.commands_sent.last - assert_match(/hangup-cause: CALL_REJECTED/, command) + assert_match(/execute-app-arg: CALL_REJECTED/, command) end - def test_send_dtmf_defaults_to_wait_true + def test_send_dtmf_sends_execute_app call = make_call(direction: :outbound) call.send_dtmf("123") @@ -272,21 +274,9 @@ def test_send_dtmf_defaults_to_wait_true assert_match(/sendmsg test-uuid/, command) assert_match(/execute-app-name: playback/, command) assert_match(%r{tone_stream://d=200;w=250;123}, command) - assert_match(/event-lock: true/, command) - end - - def test_send_dtmf_with_wait_false_no_event_lock - call = make_call(direction: :outbound) - - call.send_dtmf("123", wait: false) - - command = @session.commands_sent.last - assert_match(/sendmsg test-uuid/, command) - assert_match(/execute-app-name: playback/, command) - refute_match(/event-lock/, command) end - def test_play_audio_defaults_to_wait_true + def test_play_audio_sends_execute_app call = make_call(direction: :outbound) call.play_audio("/tmp/test.wav") @@ -295,18 +285,6 @@ def test_play_audio_defaults_to_wait_true assert_match(/sendmsg test-uuid/, command) assert_match(/execute-app-name: playback/, command) assert_match(%r{execute-app-arg: /tmp/test.wav}, command) - assert_match(/event-lock: true/, command) - end - - def test_play_audio_with_wait_false_no_event_lock - call = make_call(direction: :outbound) - - call.play_audio("/tmp/test.wav", wait: false) - - command = @session.commands_sent.last - assert_match(/sendmsg test-uuid/, command) - assert_match(/execute-app-name: playback/, command) - refute_match(/event-lock/, command) end def test_handle_event_dispatches_callstate