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.
Files changed (78) hide show
  1. checksums.yaml +5 -5
  2. data/.claude/skills/ruby-asterisk-agi/SKILL.md +317 -0
  3. data/.claude/skills/ruby-asterisk-ami/SKILL.md +187 -0
  4. data/.claude/skills/ruby-asterisk-ari/SKILL.md +174 -0
  5. data/.claude/skills/ruby-asterisk-ari-websocket/SKILL.md +148 -0
  6. data/.github/workflows/ci.yml +79 -0
  7. data/.gitignore +50 -3
  8. data/.rubocop.yml +84 -0
  9. data/CHANGELOG.md +52 -0
  10. data/Gemfile +3 -1
  11. data/LICENCE +21 -0
  12. data/README.md +454 -70
  13. data/Rakefile +3 -1
  14. data/lib/ruby-asterisk/agi/protocol.rb +160 -0
  15. data/lib/ruby-asterisk/agi/server.rb +108 -0
  16. data/lib/ruby-asterisk/agi/session.rb +187 -0
  17. data/lib/ruby-asterisk/ami/client.rb +135 -0
  18. data/lib/ruby-asterisk/ami/commands/channel.rb +55 -0
  19. data/lib/ruby-asterisk/ami/commands/conference.rb +37 -0
  20. data/lib/ruby-asterisk/ami/commands/extension.rb +17 -0
  21. data/lib/ruby-asterisk/ami/commands/mailbox.rb +21 -0
  22. data/lib/ruby-asterisk/ami/commands/monitor.rb +33 -0
  23. data/lib/ruby-asterisk/ami/commands/queue.rb +39 -0
  24. data/lib/ruby-asterisk/ami/commands/sip.rb +25 -0
  25. data/lib/ruby-asterisk/ami/commands/system.rb +50 -0
  26. data/lib/ruby-asterisk/ami/event.rb +22 -0
  27. data/lib/ruby-asterisk/ami/event_list_aggregation.rb +82 -0
  28. data/lib/ruby-asterisk/ami/parser.rb +60 -0
  29. data/lib/ruby-asterisk/ami/promise.rb +108 -0
  30. data/lib/ruby-asterisk/ami/reactor.rb +162 -0
  31. data/lib/ruby-asterisk/ari/client.rb +94 -0
  32. data/lib/ruby-asterisk/ari/resources/base.rb +23 -0
  33. data/lib/ruby-asterisk/ari/resources/bridge.rb +22 -0
  34. data/lib/ruby-asterisk/ari/resources/channel.rb +26 -0
  35. data/lib/ruby-asterisk/ari/resources/collection.rb +27 -0
  36. data/lib/ruby-asterisk/ari/resources/endpoint.rb +22 -0
  37. data/lib/ruby-asterisk/ari/resources/playback.rb +18 -0
  38. data/lib/ruby-asterisk/ari/websocket/connection.rb +143 -0
  39. data/lib/ruby-asterisk/ari/websocket/event_handlers.rb +80 -0
  40. data/lib/ruby-asterisk/ari/websocket/heartbeat.rb +65 -0
  41. data/lib/ruby-asterisk/ari/websocket/reconnect.rb +54 -0
  42. data/lib/ruby-asterisk/ari/websocket/socket_adapter.rb +23 -0
  43. data/lib/ruby-asterisk/ari/websocket.rb +155 -0
  44. data/lib/ruby-asterisk/compat.rb +7 -0
  45. data/lib/ruby-asterisk/error.rb +10 -0
  46. data/lib/ruby-asterisk/parsing_constants.rb +100 -0
  47. data/lib/ruby-asterisk/request.rb +38 -20
  48. data/lib/ruby-asterisk/response.rb +50 -137
  49. data/lib/ruby-asterisk/response_builder.rb +18 -0
  50. data/lib/ruby-asterisk/response_parser.rb +43 -0
  51. data/lib/ruby-asterisk/version.rb +4 -2
  52. data/lib/ruby-asterisk.rb +5 -147
  53. data/ruby-asterisk.gemspec +26 -17
  54. data/spec/ruby-asterisk/agi/protocol_spec.rb +239 -0
  55. data/spec/ruby-asterisk/agi/server_spec.rb +199 -0
  56. data/spec/ruby-asterisk/agi/session_spec.rb +293 -0
  57. data/spec/ruby-asterisk/ami/async_client_spec.rb +256 -0
  58. data/spec/ruby-asterisk/ami/multi_event_spec.rb +57 -0
  59. data/spec/ruby-asterisk/ami/parser_spec.rb +190 -0
  60. data/spec/ruby-asterisk/ami/reactor_spec.rb +290 -0
  61. data/spec/ruby-asterisk/ami_client_spec.rb +68 -0
  62. data/spec/ruby-asterisk/ari/client_spec.rb +168 -0
  63. data/spec/ruby-asterisk/ari/resources/bridge_spec.rb +42 -0
  64. data/spec/ruby-asterisk/ari/resources/channel_spec.rb +57 -0
  65. data/spec/ruby-asterisk/ari/resources/collection_spec.rb +66 -0
  66. data/spec/ruby-asterisk/ari/resources/endpoint_spec.rb +30 -0
  67. data/spec/ruby-asterisk/ari/resources/playback_spec.rb +33 -0
  68. data/spec/ruby-asterisk/ari/websocket_integration_spec.rb +86 -0
  69. data/spec/ruby-asterisk/ari/websocket_spec.rb +374 -0
  70. data/spec/ruby-asterisk/immutability_spec.rb +148 -0
  71. data/spec/ruby-asterisk/request_spec.rb +29 -9
  72. data/spec/ruby-asterisk/response_spec.rb +147 -148
  73. data/spec/spec_helper.rb +14 -0
  74. data/spec/support/mock_ami_server.rb +50 -0
  75. data/spec/support/mock_ari_websocket_server.rb +114 -0
  76. metadata +177 -21
  77. data/CHANGELOG +0 -3
  78. 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