ruby-asterisk 0.1.0 → 1.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 +5 -5
- data/.claude/skills/ruby-asterisk-agi/SKILL.md +317 -0
- data/.claude/skills/ruby-asterisk-ami/SKILL.md +187 -0
- data/.claude/skills/ruby-asterisk-ari/SKILL.md +174 -0
- data/.claude/skills/ruby-asterisk-ari-websocket/SKILL.md +148 -0
- data/.github/workflows/ci.yml +79 -0
- data/.gitignore +50 -3
- data/.rubocop.yml +84 -0
- data/CHANGELOG.md +52 -0
- data/Gemfile +3 -1
- data/LICENCE +21 -0
- data/README.md +454 -70
- data/Rakefile +3 -1
- data/lib/ruby-asterisk/agi/protocol.rb +160 -0
- data/lib/ruby-asterisk/agi/server.rb +108 -0
- data/lib/ruby-asterisk/agi/session.rb +187 -0
- data/lib/ruby-asterisk/ami/client.rb +135 -0
- data/lib/ruby-asterisk/ami/commands/channel.rb +55 -0
- data/lib/ruby-asterisk/ami/commands/conference.rb +37 -0
- data/lib/ruby-asterisk/ami/commands/extension.rb +17 -0
- data/lib/ruby-asterisk/ami/commands/mailbox.rb +21 -0
- data/lib/ruby-asterisk/ami/commands/monitor.rb +33 -0
- data/lib/ruby-asterisk/ami/commands/queue.rb +39 -0
- data/lib/ruby-asterisk/ami/commands/sip.rb +25 -0
- data/lib/ruby-asterisk/ami/commands/system.rb +50 -0
- data/lib/ruby-asterisk/ami/event.rb +22 -0
- data/lib/ruby-asterisk/ami/event_list_aggregation.rb +82 -0
- data/lib/ruby-asterisk/ami/parser.rb +60 -0
- data/lib/ruby-asterisk/ami/promise.rb +108 -0
- data/lib/ruby-asterisk/ami/reactor.rb +162 -0
- data/lib/ruby-asterisk/ari/client.rb +94 -0
- data/lib/ruby-asterisk/ari/resources/base.rb +23 -0
- data/lib/ruby-asterisk/ari/resources/bridge.rb +22 -0
- data/lib/ruby-asterisk/ari/resources/channel.rb +26 -0
- data/lib/ruby-asterisk/ari/resources/collection.rb +27 -0
- data/lib/ruby-asterisk/ari/resources/endpoint.rb +22 -0
- data/lib/ruby-asterisk/ari/resources/playback.rb +18 -0
- data/lib/ruby-asterisk/ari/websocket/connection.rb +143 -0
- data/lib/ruby-asterisk/ari/websocket/event_handlers.rb +80 -0
- data/lib/ruby-asterisk/ari/websocket/heartbeat.rb +65 -0
- data/lib/ruby-asterisk/ari/websocket/reconnect.rb +54 -0
- data/lib/ruby-asterisk/ari/websocket/socket_adapter.rb +23 -0
- data/lib/ruby-asterisk/ari/websocket.rb +155 -0
- data/lib/ruby-asterisk/compat.rb +7 -0
- data/lib/ruby-asterisk/error.rb +10 -0
- data/lib/ruby-asterisk/parsing_constants.rb +100 -0
- data/lib/ruby-asterisk/request.rb +38 -20
- data/lib/ruby-asterisk/response.rb +50 -137
- data/lib/ruby-asterisk/response_builder.rb +18 -0
- data/lib/ruby-asterisk/response_parser.rb +43 -0
- data/lib/ruby-asterisk/version.rb +4 -2
- data/lib/ruby-asterisk.rb +5 -147
- data/ruby-asterisk.gemspec +26 -17
- data/spec/ruby-asterisk/agi/protocol_spec.rb +239 -0
- data/spec/ruby-asterisk/agi/server_spec.rb +199 -0
- data/spec/ruby-asterisk/agi/session_spec.rb +293 -0
- data/spec/ruby-asterisk/ami/async_client_spec.rb +256 -0
- data/spec/ruby-asterisk/ami/multi_event_spec.rb +57 -0
- data/spec/ruby-asterisk/ami/parser_spec.rb +190 -0
- data/spec/ruby-asterisk/ami/reactor_spec.rb +290 -0
- data/spec/ruby-asterisk/ami_client_spec.rb +68 -0
- data/spec/ruby-asterisk/ari/client_spec.rb +168 -0
- data/spec/ruby-asterisk/ari/resources/bridge_spec.rb +42 -0
- data/spec/ruby-asterisk/ari/resources/channel_spec.rb +57 -0
- data/spec/ruby-asterisk/ari/resources/collection_spec.rb +66 -0
- data/spec/ruby-asterisk/ari/resources/endpoint_spec.rb +30 -0
- data/spec/ruby-asterisk/ari/resources/playback_spec.rb +33 -0
- data/spec/ruby-asterisk/ari/websocket_integration_spec.rb +86 -0
- data/spec/ruby-asterisk/ari/websocket_spec.rb +374 -0
- data/spec/ruby-asterisk/immutability_spec.rb +148 -0
- data/spec/ruby-asterisk/request_spec.rb +29 -9
- data/spec/ruby-asterisk/response_spec.rb +147 -148
- data/spec/spec_helper.rb +14 -0
- data/spec/support/mock_ami_server.rb +50 -0
- data/spec/support/mock_ari_websocket_server.rb +114 -0
- metadata +177 -21
- data/CHANGELOG +0 -3
- data/spec/ruby-asterisk/ruby_asterisk_spec.rb +0 -150
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyAsterisk
|
|
4
|
+
module AGI
|
|
5
|
+
# Low-level formatter and parser for the FastAGI wire protocol.
|
|
6
|
+
module Protocol
|
|
7
|
+
ENV_LINE = /\A(agi_\w+):\s*(.*)/
|
|
8
|
+
|
|
9
|
+
# Matches a response carrying a result value: CODE result=VALUE [extra]
|
|
10
|
+
RESULT_RE = /\A(\d{3})\s+result=(\S+)(?:\s+(.*?))?\s*\z/
|
|
11
|
+
|
|
12
|
+
# Matches any response line (fallback): CODE[-space]message
|
|
13
|
+
CODE_RE = /\A(\d{3})(?:[-\s](.*?))?\s*\z/
|
|
14
|
+
|
|
15
|
+
# Format an AGI command string ready for sending to Asterisk.
|
|
16
|
+
#
|
|
17
|
+
# @param command [String] AGI command verb (e.g. "ANSWER", "EXEC")
|
|
18
|
+
# @param args [Array<#to_s>] zero or more arguments
|
|
19
|
+
# @return [String] space-joined, \n-terminated line
|
|
20
|
+
def self.format_command(command, *args)
|
|
21
|
+
parts = [command.to_s]
|
|
22
|
+
args.each { |a| parts << escape_argument(a.to_s) }
|
|
23
|
+
"#{parts.join(' ')}\n"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Escape a single AGI command argument.
|
|
27
|
+
#
|
|
28
|
+
# Plain alphanumeric/dash/underscore/slash tokens are returned as-is.
|
|
29
|
+
# Empty strings and strings containing whitespace, quotes, or backslashes
|
|
30
|
+
# are double-quoted with internal special characters escaped.
|
|
31
|
+
#
|
|
32
|
+
# @param arg [String]
|
|
33
|
+
# @return [String]
|
|
34
|
+
def self.escape_argument(arg)
|
|
35
|
+
return '""' if arg.empty?
|
|
36
|
+
return arg unless arg.match?(/[\s"\\]/)
|
|
37
|
+
|
|
38
|
+
escaped = arg.gsub('\\') { '\\\\' }.gsub('"') { '\\"' }
|
|
39
|
+
"\"#{escaped}\""
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Always wrap +arg+ in double quotes, escaping any internal backslashes
|
|
43
|
+
# and double-quotes. Use for AGI arguments that Asterisk requires to be
|
|
44
|
+
# quoted regardless of content (filenames, variable values, messages).
|
|
45
|
+
#
|
|
46
|
+
# @param arg [String, #to_s]
|
|
47
|
+
# @return [String]
|
|
48
|
+
def self.quote(arg)
|
|
49
|
+
escaped = arg.to_s.gsub('\\') { '\\\\' }.gsub('"') { '\\"' }
|
|
50
|
+
"\"#{escaped}\""
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Parse a single raw AGI response line into a frozen Hash.
|
|
54
|
+
#
|
|
55
|
+
# @param line [String, nil]
|
|
56
|
+
# @return [Hash] { code: Integer, result: String|nil, data: String|nil }
|
|
57
|
+
def self.parse_response(line)
|
|
58
|
+
return { code: 0, result: nil, data: 'Connection closed' }.freeze if line.nil?
|
|
59
|
+
|
|
60
|
+
stripped = line.chomp
|
|
61
|
+
return { code: 0, result: nil, data: stripped }.freeze if stripped.empty?
|
|
62
|
+
|
|
63
|
+
parse_result_line(stripped) || parse_code_line(stripped) ||
|
|
64
|
+
{ code: 0, result: nil, data: stripped }.freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @param response [Hash] as returned by {parse_response}
|
|
68
|
+
# @return [Boolean] true when the response code signals an error
|
|
69
|
+
def self.error?(response)
|
|
70
|
+
response[:code] >= 500
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Parse a single line from the initial AGI environment block.
|
|
74
|
+
#
|
|
75
|
+
# @param line [String]
|
|
76
|
+
# @return [Array(String, String), nil] [key, value] pair or nil if not an env line
|
|
77
|
+
def self.parse_env_line(line)
|
|
78
|
+
m = ENV_LINE.match(line.chomp)
|
|
79
|
+
return nil unless m
|
|
80
|
+
|
|
81
|
+
[m[1], m[2].strip]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Read and parse the AGI environment block from an I/O source.
|
|
85
|
+
#
|
|
86
|
+
# Reads lines via +io.gets+ until a blank line or EOF. Each recognised
|
|
87
|
+
# +agi_key: value+ line is stored in the returned Hash. Unrecognised lines
|
|
88
|
+
# are yielded to the optional block (e.g. for debug logging).
|
|
89
|
+
#
|
|
90
|
+
# @param io [#gets]
|
|
91
|
+
# @yieldparam line [String] each unrecognised line (without newline)
|
|
92
|
+
# @return [Hash{String=>String}]
|
|
93
|
+
def self.parse_env_block(io)
|
|
94
|
+
env = {}
|
|
95
|
+
while (raw = io.gets)
|
|
96
|
+
chopped = raw.chomp
|
|
97
|
+
break if chopped.empty?
|
|
98
|
+
|
|
99
|
+
pair = parse_env_line(chopped)
|
|
100
|
+
if pair
|
|
101
|
+
env[pair[0]] = pair[1]
|
|
102
|
+
elsif block_given?
|
|
103
|
+
yield chopped
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
env
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Complete a (possibly multi-line) 520 error response.
|
|
110
|
+
#
|
|
111
|
+
# Returns +first_response+ unchanged when +first_line+ does not begin with
|
|
112
|
+
# a continuation marker (+NNN-+). Otherwise reads continuation lines from
|
|
113
|
+
# +io+ until the +NNN <text>+ closing line, then returns a new frozen
|
|
114
|
+
# response hash with the accumulated body as +:data+.
|
|
115
|
+
#
|
|
116
|
+
# @param first_line [String] the already-read first response line (chomped)
|
|
117
|
+
# @param first_response [Hash] as returned by {parse_response}
|
|
118
|
+
# @param io [#gets]
|
|
119
|
+
# @return [Hash]
|
|
120
|
+
def self.collect_multiline_error(first_line, first_response, io)
|
|
121
|
+
return first_response unless first_line.match?(/\A\d{3}-/)
|
|
122
|
+
|
|
123
|
+
lines = [first_line.sub(/\A\d{3}-/, '')]
|
|
124
|
+
while (l = io.gets)
|
|
125
|
+
chopped = l.chomp
|
|
126
|
+
if chopped.match?(/\A\d{3}\s/)
|
|
127
|
+
lines << chopped.sub(/\A\d{3}\s*/, '')
|
|
128
|
+
break
|
|
129
|
+
else
|
|
130
|
+
lines << chopped.sub(/\A\d{3}-/, '')
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
body = lines.reject(&:empty?).join(' ')
|
|
135
|
+
{ code: first_response[:code], result: nil, data: body.freeze }.freeze
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# -- private helpers ----------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def self.parse_result_line(line)
|
|
141
|
+
m = RESULT_RE.match(line)
|
|
142
|
+
return nil unless m
|
|
143
|
+
|
|
144
|
+
extra = m[3]&.strip
|
|
145
|
+
data = extra.nil? || extra.empty? ? nil : extra.delete_prefix('(').delete_suffix(')')
|
|
146
|
+
{ code: m[1].to_i, result: m[2].freeze, data: data&.freeze }.freeze
|
|
147
|
+
end
|
|
148
|
+
private_class_method :parse_result_line
|
|
149
|
+
|
|
150
|
+
def self.parse_code_line(line)
|
|
151
|
+
m = CODE_RE.match(line)
|
|
152
|
+
return nil unless m
|
|
153
|
+
|
|
154
|
+
msg = m[2]&.strip || ''
|
|
155
|
+
{ code: m[1].to_i, result: nil, data: (msg.empty? ? nil : msg.freeze) }.freeze
|
|
156
|
+
end
|
|
157
|
+
private_class_method :parse_code_line
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'logger'
|
|
5
|
+
require 'async'
|
|
6
|
+
require_relative 'session'
|
|
7
|
+
require_relative '../../ruby-asterisk/error'
|
|
8
|
+
require_relative '../../ruby-asterisk/compat'
|
|
9
|
+
|
|
10
|
+
module RubyAsterisk
|
|
11
|
+
module AGI
|
|
12
|
+
# FastAGI TCP server backed by an Async Fiber scheduler.
|
|
13
|
+
#
|
|
14
|
+
# Each incoming Asterisk connection is handled in its own Fiber, so all
|
|
15
|
+
# concurrent sessions share a single OS thread and their socket IO yields
|
|
16
|
+
# the Fiber rather than blocking.
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
# server = RubyAsterisk::AGI::Server.new('0.0.0.0', 4573)
|
|
20
|
+
# server.handle { |session| session.answer }
|
|
21
|
+
# server.run # blocks until server.stop is called
|
|
22
|
+
class Server
|
|
23
|
+
attr_reader :host, :port, :running
|
|
24
|
+
|
|
25
|
+
alias running? running
|
|
26
|
+
|
|
27
|
+
def initialize(host, port, logger: Logger.new($stdout))
|
|
28
|
+
@host = host
|
|
29
|
+
@port = port.to_i
|
|
30
|
+
@logger = logger
|
|
31
|
+
@handler = nil
|
|
32
|
+
@running = false
|
|
33
|
+
@server = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Register the per-connection handler block.
|
|
37
|
+
#
|
|
38
|
+
# @yield [RubyAsterisk::AGI::Session]
|
|
39
|
+
# @return [self]
|
|
40
|
+
def handle(&block)
|
|
41
|
+
@handler = block
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Start accepting connections (blocks until {#stop} is called).
|
|
46
|
+
#
|
|
47
|
+
# @raise [RubyAsterisk::Error] if no handler has been registered
|
|
48
|
+
def run
|
|
49
|
+
raise Error, 'No handler registered' unless @handler
|
|
50
|
+
|
|
51
|
+
Sync do |parent|
|
|
52
|
+
@server = TCPServer.new(@host, @port)
|
|
53
|
+
@port = @server.addr[1]
|
|
54
|
+
@running = true
|
|
55
|
+
@logger.info("AGI server listening on #{@host}:#{@port}")
|
|
56
|
+
accept_loop(parent)
|
|
57
|
+
end
|
|
58
|
+
ensure
|
|
59
|
+
@running = false
|
|
60
|
+
close_server
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Signal the server to stop accepting new connections.
|
|
64
|
+
# Active sessions continue until they finish naturally.
|
|
65
|
+
def stop
|
|
66
|
+
return unless @running
|
|
67
|
+
|
|
68
|
+
@running = false
|
|
69
|
+
close_server
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def accept_loop(parent)
|
|
75
|
+
loop do
|
|
76
|
+
begin
|
|
77
|
+
socket = @server.accept
|
|
78
|
+
rescue IOError, Errno::EBADF
|
|
79
|
+
break # server socket closed via stop — normal shutdown
|
|
80
|
+
rescue SystemCallError => e
|
|
81
|
+
# Transient accept error (e.g. EMFILE/ENFILE fd exhaustion,
|
|
82
|
+
# ECONNABORTED): log and keep serving instead of downing the server.
|
|
83
|
+
@logger.error("AGI accept error: #{e.message}")
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
parent.async { serve(socket) }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def serve(socket)
|
|
91
|
+
session = Session.new(socket, logger: @logger)
|
|
92
|
+
session.read_env
|
|
93
|
+
@handler.call(session)
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
@logger.error("AGI session error: #{e.message}")
|
|
96
|
+
ensure
|
|
97
|
+
socket.close rescue nil # rubocop:disable Style/RescueModifier
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def close_server
|
|
101
|
+
@server&.close
|
|
102
|
+
@server = nil
|
|
103
|
+
rescue StandardError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
require_relative 'protocol'
|
|
5
|
+
require_relative '../../ruby-asterisk/error'
|
|
6
|
+
|
|
7
|
+
module RubyAsterisk
|
|
8
|
+
module AGI
|
|
9
|
+
# Wraps a single FastAGI connection lifecycle.
|
|
10
|
+
#
|
|
11
|
+
# Parses the AGI environment block sent by Asterisk at connection start,
|
|
12
|
+
# then provides command methods that write AGI commands and return parsed
|
|
13
|
+
# response hashes. Under an Async Fiber scheduler the socket reads/writes
|
|
14
|
+
# yield the Fiber rather than blocking a thread.
|
|
15
|
+
class Session
|
|
16
|
+
attr_reader :env, :socket
|
|
17
|
+
|
|
18
|
+
# @param env_timeout [Numeric, nil] seconds to wait for the initial AGI
|
|
19
|
+
# environment block before giving up (guards against slowloris-style
|
|
20
|
+
# connect-and-stall clients). Applied only to the handshake, never to
|
|
21
|
+
# command reads (which may legitimately block for minutes, e.g. STREAM
|
|
22
|
+
# FILE). Pass nil to disable. No-op on Ruby 3.1 (IO#timeout= shim).
|
|
23
|
+
def initialize(socket, logger: Logger.new($stdout), env_timeout: 10)
|
|
24
|
+
@socket = socket
|
|
25
|
+
@logger = logger
|
|
26
|
+
@env = {}
|
|
27
|
+
@env_timeout = env_timeout
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Read the initial AGI environment block (agi_key: value lines until blank line).
|
|
31
|
+
#
|
|
32
|
+
# @raise [IO::TimeoutError] on Ruby >= 3.2 if the peer stalls beyond env_timeout
|
|
33
|
+
def read_env
|
|
34
|
+
with_env_timeout do
|
|
35
|
+
@env = Protocol.parse_env_block(@socket) do |line|
|
|
36
|
+
@logger.debug("AGI env: unexpected line: #{line}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Send a raw AGI command string and return the parsed response Hash.
|
|
42
|
+
#
|
|
43
|
+
# Handles Asterisk's two-line 520- syntax-error format by accumulating
|
|
44
|
+
# continuation lines until the closing `520 End of proper usage.` line.
|
|
45
|
+
#
|
|
46
|
+
# Any embedded CR/LF in +command_string+ is stripped before sending: AGI is
|
|
47
|
+
# a line-oriented protocol, so a newline in a (possibly caller-controlled)
|
|
48
|
+
# argument or identifier would otherwise inject additional commands.
|
|
49
|
+
#
|
|
50
|
+
# @param command_string [String]
|
|
51
|
+
# @return [Hash] { code: Integer, result: String|nil, data: String|nil }
|
|
52
|
+
# @raise [RubyAsterisk::Error] on EOF or 5xx response
|
|
53
|
+
# @raise [RubyAsterisk::HangupError] if Asterisk sends an out-of-band HANGUP
|
|
54
|
+
def execute(command_string)
|
|
55
|
+
line = command_string.to_s.delete("\r\n")
|
|
56
|
+
@socket.write("#{line}\n")
|
|
57
|
+
line = @socket.gets
|
|
58
|
+
raise Error, 'Connection closed by Asterisk' unless line
|
|
59
|
+
raise HangupError, 'Channel hung up' if line.chomp == 'HANGUP'
|
|
60
|
+
|
|
61
|
+
response = Protocol.parse_response(line)
|
|
62
|
+
|
|
63
|
+
if response[:code] >= 500
|
|
64
|
+
response = Protocol.collect_multiline_error(line.chomp, response, @socket)
|
|
65
|
+
raise Error, "AGI error (#{response[:code]}): #{response[:data]}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
response
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# -- Existing wrappers (rewritten to use Protocol.format_command) -------
|
|
72
|
+
|
|
73
|
+
def answer
|
|
74
|
+
execute('ANSWER')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def hangup(channel = nil)
|
|
78
|
+
channel ? execute("HANGUP #{channel}") : execute('HANGUP')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def stream_file(filename, escape_digits = '')
|
|
82
|
+
execute("STREAM FILE #{Protocol.quote(filename)} #{Protocol.quote(escape_digits)}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def say_digits(digits, escape_digits = '')
|
|
86
|
+
execute("SAY DIGITS #{digits} #{Protocol.escape_argument(escape_digits)}")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def say_number(number, escape_digits = '')
|
|
90
|
+
execute("SAY NUMBER #{number} #{Protocol.escape_argument(escape_digits)}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def exec(application, *args)
|
|
94
|
+
execute("EXEC #{application} #{Protocol.quote(args.join(','))}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def set_variable(name, value)
|
|
98
|
+
execute("SET VARIABLE #{name} #{Protocol.quote(value)}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def get_variable(name)
|
|
102
|
+
execute("GET VARIABLE #{name}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def get_data(filename, timeout: 5000, max_digits: 1024)
|
|
106
|
+
execute("GET DATA #{Protocol.quote(filename)} #{timeout} #{max_digits}")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def verbose(message, level: 1)
|
|
110
|
+
execute("VERBOSE #{Protocol.quote(message)} #{level}")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# -- Telephony commands -------------------------------------------------
|
|
114
|
+
|
|
115
|
+
# Originate a call leg via Dial dialplan application.
|
|
116
|
+
def dial(target, timeout: 30, options: '')
|
|
117
|
+
args = [target, timeout.to_s]
|
|
118
|
+
args << options unless options.to_s.empty?
|
|
119
|
+
execute("EXEC Dial #{Protocol.quote(args.join(','))}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Block until a DTMF digit is pressed or timeout expires.
|
|
123
|
+
# Like every verb this returns the parsed response Hash
|
|
124
|
+
# { code:, result:, data: }; the pressed key's decimal ASCII value
|
|
125
|
+
# (or -1 on timeout) is in response[:result].
|
|
126
|
+
def wait_for_digit(timeout_ms = 5000)
|
|
127
|
+
execute("WAIT FOR DIGIT #{timeout_ms}")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Record audio to a file.
|
|
131
|
+
def record_file(filename, format: 'wav', escape_digits: '#', timeout_ms: -1, offset: 0, beep: true, silence: nil)
|
|
132
|
+
cmd = "RECORD FILE #{Protocol.quote(filename)} #{format} " \
|
|
133
|
+
"#{Protocol.quote(escape_digits)} #{timeout_ms} #{offset}"
|
|
134
|
+
cmd += ' BEEP' if beep
|
|
135
|
+
cmd += " s=#{silence}" if silence
|
|
136
|
+
execute(cmd)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def send_text(text)
|
|
140
|
+
execute("SEND TEXT #{Protocol.quote(text)}")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def send_image(filename)
|
|
144
|
+
execute("SEND IMAGE #{filename}")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def channel_status(channel = nil)
|
|
148
|
+
channel ? execute("CHANNEL STATUS #{channel}") : execute('CHANNEL STATUS')
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# -- AstDB commands -----------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def database_get(family, key)
|
|
154
|
+
execute("DATABASE GET #{family} #{key}")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def database_put(family, key, value)
|
|
158
|
+
execute("DATABASE PUT #{family} #{key} #{Protocol.quote(value)}")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def database_del(family, key)
|
|
162
|
+
execute("DATABASE DEL #{family} #{key}")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def database_deltree(family, keytree = nil)
|
|
166
|
+
keytree ? execute("DATABASE DELTREE #{family} #{keytree}") : execute("DATABASE DELTREE #{family}")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
# Apply @env_timeout to the socket for the duration of the block, then
|
|
172
|
+
# restore it. No-op when the timeout is nil or the socket does not support
|
|
173
|
+
# IO#timeout= (Ruby 3.1 shim is a no-op; test doubles simply skip it).
|
|
174
|
+
def with_env_timeout
|
|
175
|
+
return yield if @env_timeout.nil? || !@socket.respond_to?(:timeout=)
|
|
176
|
+
|
|
177
|
+
previous = @socket.respond_to?(:timeout) ? @socket.timeout : nil
|
|
178
|
+
@socket.timeout = @env_timeout
|
|
179
|
+
begin
|
|
180
|
+
yield
|
|
181
|
+
ensure
|
|
182
|
+
@socket.timeout = previous
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby-asterisk/version'
|
|
4
|
+
require 'ruby-asterisk/error'
|
|
5
|
+
require 'ruby-asterisk/request'
|
|
6
|
+
require 'ruby-asterisk/response'
|
|
7
|
+
require 'ruby-asterisk/response_builder'
|
|
8
|
+
require 'ruby-asterisk/ami/promise'
|
|
9
|
+
require 'ruby-asterisk/ami/reactor'
|
|
10
|
+
|
|
11
|
+
# Commands
|
|
12
|
+
Dir[File.join(File.dirname(__FILE__), 'commands', '*.rb')].each { |file| require file }
|
|
13
|
+
|
|
14
|
+
module RubyAsterisk
|
|
15
|
+
module AMI
|
|
16
|
+
##
|
|
17
|
+
# Asynchronous AMI client.
|
|
18
|
+
#
|
|
19
|
+
# {#execute} registers a {Promise} for the outgoing command and writes it
|
|
20
|
+
# to the socket via the internal {Reactor}, returning the Promise immediately
|
|
21
|
+
# without blocking. Callers obtain the response by calling {Promise#value}
|
|
22
|
+
# on the returned Promise, which blocks only until the matching reply arrives
|
|
23
|
+
# (or a per-command timeout fires).
|
|
24
|
+
#
|
|
25
|
+
# Data pipeline:
|
|
26
|
+
# Client (external thread)
|
|
27
|
+
# → Reactor#send_command (Thread::Queue + IO.pipe doorbell)
|
|
28
|
+
# → intake Fiber → socket write
|
|
29
|
+
# → socket read → reader Fiber → Parser
|
|
30
|
+
# → dispatcher → Promise#resolve (Mutex+CV)
|
|
31
|
+
# → Client#execute caller (blocks on Promise#value)
|
|
32
|
+
#
|
|
33
|
+
class Client
|
|
34
|
+
include Commands::System
|
|
35
|
+
include Commands::Channel
|
|
36
|
+
include Commands::Conference
|
|
37
|
+
include Commands::Extension
|
|
38
|
+
include Commands::Queue
|
|
39
|
+
include Commands::Mailbox
|
|
40
|
+
include Commands::Sip
|
|
41
|
+
include Commands::Monitor
|
|
42
|
+
|
|
43
|
+
attr_accessor :host, :port, :connected, :timeout
|
|
44
|
+
|
|
45
|
+
def initialize(host:, port:)
|
|
46
|
+
self.host = host.to_s
|
|
47
|
+
self.port = port.to_i
|
|
48
|
+
self.connected = false
|
|
49
|
+
@timeout = 5
|
|
50
|
+
@reactor = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Connect to the Asterisk AMI server and start the async reactor.
|
|
54
|
+
#
|
|
55
|
+
# @return [true, false]
|
|
56
|
+
def connect
|
|
57
|
+
@reactor = Reactor.new(host, port,
|
|
58
|
+
on_event: method(:handle_event),
|
|
59
|
+
on_disconnect: method(:handle_disconnect))
|
|
60
|
+
@reactor.start
|
|
61
|
+
self.connected = true
|
|
62
|
+
true
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
puts "Connection error: #{e.message}"
|
|
65
|
+
@reactor = nil
|
|
66
|
+
self.connected = false
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Disconnect from Asterisk, stop the reactor, and reject pending promises.
|
|
71
|
+
#
|
|
72
|
+
# @return [true, false]
|
|
73
|
+
def disconnect
|
|
74
|
+
@reactor&.stop
|
|
75
|
+
@reactor&.reject_all_promises(RuntimeError.new('Client disconnected'))
|
|
76
|
+
@reactor = nil
|
|
77
|
+
self.connected = false
|
|
78
|
+
true
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
puts e
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def login(username:, secret:)
|
|
85
|
+
connect unless connected
|
|
86
|
+
raise Error, "Unable to connect to AMI at #{host}:#{port}" unless connected
|
|
87
|
+
|
|
88
|
+
execute 'Login', { 'Username' => username, 'Secret' => secret, 'Event' => 'On' }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def logoff
|
|
92
|
+
execute 'Logoff'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
##
|
|
96
|
+
# Send an AMI command asynchronously.
|
|
97
|
+
#
|
|
98
|
+
# A caller-supplied 'ActionID' option becomes the request's own ActionID
|
|
99
|
+
# instead of an extra header: two ActionID lines in one frame would leave
|
|
100
|
+
# the response uncorrelated with its Promise.
|
|
101
|
+
#
|
|
102
|
+
# @param command [String] AMI action name (e.g. 'Ping', 'Login')
|
|
103
|
+
# @param options [Hash] additional AMI headers
|
|
104
|
+
# @param timeout [Numeric, nil] seconds {Promise#value} waits by default
|
|
105
|
+
# for this command only (does not affect other commands); nil waits
|
|
106
|
+
# indefinitely
|
|
107
|
+
# @return [Promise] call {Promise#value} to obtain the {Response}
|
|
108
|
+
# @raise [RubyAsterisk::Error] if the client is not connected
|
|
109
|
+
def execute(command, options = {}, timeout: @timeout)
|
|
110
|
+
raise Error, 'Not connected to AMI' unless @reactor
|
|
111
|
+
|
|
112
|
+
options = options.dup
|
|
113
|
+
action_id = options.delete('ActionID')
|
|
114
|
+
request = Request.new(command, options, action_id: action_id)
|
|
115
|
+
promise = Promise.new(action_id: request.action_id, command_type: command, timeout: timeout)
|
|
116
|
+
promise.on_timeout = -> { @reactor&.unregister_promise(request.action_id) }
|
|
117
|
+
@reactor.register_promise(request.action_id, promise)
|
|
118
|
+
# Push the whole frame in one operation so commands issued from concurrent
|
|
119
|
+
# threads cannot interleave their header lines on the wire.
|
|
120
|
+
@reactor.send_command(request.commands.join)
|
|
121
|
+
promise
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def handle_event(_event)
|
|
127
|
+
# Async events received outside a command cycle — available for future use.
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def handle_disconnect
|
|
131
|
+
self.connected = false
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyAsterisk
|
|
4
|
+
module AMI
|
|
5
|
+
module Commands
|
|
6
|
+
##
|
|
7
|
+
#
|
|
8
|
+
# Channel commands
|
|
9
|
+
#
|
|
10
|
+
module Channel
|
|
11
|
+
def core_show_channels
|
|
12
|
+
execute 'CoreShowChannels'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def status(channel: nil, action_id: nil)
|
|
16
|
+
execute 'Status', { 'Channel' => channel, 'ActionID' => action_id }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def originate(channel, context, callee, priority, variable: nil, caller_id: nil, timeout: 30_000, async: nil)
|
|
20
|
+
execute 'Originate',
|
|
21
|
+
{ 'Channel' => channel, 'Context' => context, 'Exten' => callee, 'Priority' => priority,
|
|
22
|
+
'CallerID' => caller_id || channel, 'Timeout' => timeout.to_s, 'Variable' => variable,
|
|
23
|
+
'Async' => async },
|
|
24
|
+
timeout: [@timeout, timeout / 1000].max
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def originate_app(channel:, application:, data:, async:)
|
|
28
|
+
execute 'Originate',
|
|
29
|
+
{ 'Channel' => channel, 'Application' => application, 'Data' => data, 'Timeout' => '30000',
|
|
30
|
+
'Async' => async }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def channels
|
|
34
|
+
execute 'Command', { 'Command' => 'show channels' }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def redirect(channel, context, callee, priority, variable: nil, caller_id: nil, timeout: 30_000)
|
|
38
|
+
execute 'Redirect',
|
|
39
|
+
{ 'Channel' => channel, 'Context' => context, 'Exten' => callee, 'Priority' => priority,
|
|
40
|
+
'CallerID' => caller_id || channel, 'Timeout' => timeout.to_s, 'Variable' => variable },
|
|
41
|
+
timeout: [@timeout, timeout / 1000].max
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def hangup(channel)
|
|
45
|
+
execute 'Hangup', { 'Channel' => channel }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def atxfer(channel:, exten:, context:, priority: '1')
|
|
49
|
+
execute 'Atxfer',
|
|
50
|
+
{ 'Channel' => channel, 'Exten' => exten.to_s, 'Context' => context, 'Priority' => priority }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyAsterisk
|
|
4
|
+
module AMI
|
|
5
|
+
module Commands
|
|
6
|
+
##
|
|
7
|
+
#
|
|
8
|
+
# Conference commands
|
|
9
|
+
#
|
|
10
|
+
module Conference
|
|
11
|
+
def meet_me_list
|
|
12
|
+
execute 'MeetMeList'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def confbridges
|
|
16
|
+
execute 'ConfbridgeListRooms'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def confbridge(conference)
|
|
20
|
+
execute 'ConfbridgeList', { 'Conference' => conference }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def confbridge_mute(conference:, channel:)
|
|
24
|
+
execute 'ConfbridgeMute', { 'Conference' => conference, 'Channel' => channel }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def confbridge_unmute(conference:, channel:)
|
|
28
|
+
execute 'ConfbridgeUnmute', { 'Conference' => conference, 'Channel' => channel }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def confbridge_kick(conference:, channel:)
|
|
32
|
+
execute 'ConfbridgeKick', { 'Conference' => conference, 'Channel' => channel }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyAsterisk
|
|
4
|
+
module AMI
|
|
5
|
+
module Commands
|
|
6
|
+
##
|
|
7
|
+
#
|
|
8
|
+
# Extension commands
|
|
9
|
+
#
|
|
10
|
+
module Extension
|
|
11
|
+
def extension_state(exten:, context:, action_id: nil)
|
|
12
|
+
execute 'ExtensionState', { 'Exten' => exten, 'Context' => context, 'ActionID' => action_id }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|