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,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyAsterisk
|
|
4
|
+
module AMI
|
|
5
|
+
module Commands
|
|
6
|
+
##
|
|
7
|
+
#
|
|
8
|
+
# Mailbox commands
|
|
9
|
+
#
|
|
10
|
+
module Mailbox
|
|
11
|
+
def mailbox_status(mailbox:, context: 'default')
|
|
12
|
+
execute 'MailboxStatus', { 'Mailbox' => "#{mailbox}@#{context}" }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def mailbox_count(mailbox:, context: 'default')
|
|
16
|
+
execute 'MailboxCount', { 'Mailbox' => "#{mailbox}@#{context}" }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyAsterisk
|
|
4
|
+
module AMI
|
|
5
|
+
module Commands
|
|
6
|
+
##
|
|
7
|
+
#
|
|
8
|
+
# Monitor commands
|
|
9
|
+
#
|
|
10
|
+
module Monitor
|
|
11
|
+
def monitor(channel, mix: false, file: nil, format: 'wav')
|
|
12
|
+
execute 'Monitor', { 'Channel' => channel, 'File' => file, 'Mix' => mix, 'Format' => format }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def stop_monitor(channel)
|
|
16
|
+
execute 'StopMonitor', { 'Channel' => channel }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def pause_monitor(channel)
|
|
20
|
+
execute 'PauseMonitor', { 'Channel' => channel }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def unpause_monitor(channel)
|
|
24
|
+
execute 'UnpauseMonitor', { 'Channel' => channel }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def change_monitor(channel:, file:)
|
|
28
|
+
execute 'ChangeMonitor', { 'Channel' => channel, 'File' => file }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyAsterisk
|
|
4
|
+
module AMI
|
|
5
|
+
module Commands
|
|
6
|
+
##
|
|
7
|
+
#
|
|
8
|
+
# Queue commands
|
|
9
|
+
#
|
|
10
|
+
module Queue
|
|
11
|
+
def queues
|
|
12
|
+
execute 'Queues', {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def queue_add(queue, interface, penalty: 2, paused: false, member_name: '')
|
|
16
|
+
execute 'QueueAdd',
|
|
17
|
+
{ 'Queue' => queue, 'Interface' => interface, 'Penalty' => penalty, 'Paused' => paused,
|
|
18
|
+
'MemberName' => member_name }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def queue_remove(queue:, interface:)
|
|
22
|
+
execute 'QueueRemove', { 'Queue' => queue, 'Interface' => interface }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def queue_status
|
|
26
|
+
execute 'QueueStatus'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def queue_summary(queue)
|
|
30
|
+
execute 'QueueSummary', { 'Queue' => queue }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def queue_pause(interface:, paused:, queue:, reason: 'none')
|
|
34
|
+
execute 'QueuePause', { 'Interface' => interface, 'Paused' => paused, 'Queue' => queue, 'Reason' => reason }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyAsterisk
|
|
4
|
+
module AMI
|
|
5
|
+
module Commands
|
|
6
|
+
##
|
|
7
|
+
#
|
|
8
|
+
# Sip commands
|
|
9
|
+
#
|
|
10
|
+
module Sip
|
|
11
|
+
def sip_peers
|
|
12
|
+
execute 'SIPpeers'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def sip_show_peer(peer)
|
|
16
|
+
execute 'SIPshowpeer', { 'Peer' => peer }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def sip_show_registry
|
|
20
|
+
execute 'SIPshowregistry'
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyAsterisk
|
|
4
|
+
module AMI
|
|
5
|
+
module Commands
|
|
6
|
+
##
|
|
7
|
+
#
|
|
8
|
+
# System commands
|
|
9
|
+
#
|
|
10
|
+
module System
|
|
11
|
+
def parked_calls
|
|
12
|
+
execute 'ParkedCalls'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def device_state_list
|
|
16
|
+
execute 'DeviceStateList'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def skinny_devices
|
|
20
|
+
execute 'SKINNYdevices'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def skinny_lines
|
|
24
|
+
execute 'SKINNYlines'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ping
|
|
28
|
+
execute 'Ping'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def event_mask(event_mask = 'off')
|
|
32
|
+
execute 'Events', { 'EventMask' => event_mask }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A negative Timeout tells Asterisk to wait indefinitely, so the Promise
|
|
36
|
+
# must not impose a deadline either: #value then blocks until an event
|
|
37
|
+
# arrives (pass an explicit `value(seconds)` to bound the wait).
|
|
38
|
+
def wait_event(timeout: -1)
|
|
39
|
+
seconds = timeout.to_i
|
|
40
|
+
execute 'WaitEvent', { 'Timeout' => timeout },
|
|
41
|
+
timeout: (seconds.negative? ? nil : [@timeout, seconds].max)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def command(command)
|
|
45
|
+
execute 'Command', { 'Command' => command }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyAsterisk
|
|
4
|
+
module AMI
|
|
5
|
+
##
|
|
6
|
+
# Immutable value object representing a parsed AMI event.
|
|
7
|
+
# All attributes are frozen for thread-safe sharing.
|
|
8
|
+
#
|
|
9
|
+
class Event
|
|
10
|
+
attr_reader :name, :headers, :raw
|
|
11
|
+
|
|
12
|
+
# @param headers [Hash] frozen hash of parsed AMI headers (Key => Value strings)
|
|
13
|
+
# @param raw [String] frozen raw message string including the trailing \r\n\r\n
|
|
14
|
+
def initialize(headers, raw)
|
|
15
|
+
@name = headers['Event'].freeze
|
|
16
|
+
@headers = headers
|
|
17
|
+
@raw = raw
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyAsterisk
|
|
4
|
+
module AMI
|
|
5
|
+
# Frame-dispatch and EventList aggregation logic for {Reactor}.
|
|
6
|
+
#
|
|
7
|
+
# AMI list actions reply with an ack frame (Response: Success / EventList:
|
|
8
|
+
# start), one Event frame per item, then a terminating *Complete event —
|
|
9
|
+
# each a separate \r\n\r\n frame carrying the same ActionID. These methods
|
|
10
|
+
# buffer the follow-up events by ActionID and resolve the promise only once
|
|
11
|
+
# the Complete event arrives, so the full frame set reaches ResponseParser.
|
|
12
|
+
#
|
|
13
|
+
# Expects the includer to provide @promises, @promises_mutex, @buffers,
|
|
14
|
+
# @on_event and #resolve_promise.
|
|
15
|
+
module EventListAggregation
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def dispatch(msg)
|
|
19
|
+
case msg[:type]
|
|
20
|
+
when :response
|
|
21
|
+
handle_response(msg[:action_id], msg[:headers], msg[:raw])
|
|
22
|
+
when :event
|
|
23
|
+
handle_event(msg[:event])
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A response frame either completes a single-reply command immediately or,
|
|
28
|
+
# when it opens an EventList, begins buffering the follow-up Event frames.
|
|
29
|
+
def handle_response(action_id, headers, raw)
|
|
30
|
+
if list_start?(headers)
|
|
31
|
+
start_buffer(action_id, raw)
|
|
32
|
+
else
|
|
33
|
+
resolve_promise(action_id, raw)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Event frames carrying the ActionID of a buffering command are appended to
|
|
38
|
+
# that command's reply; the terminating Complete event resolves the promise
|
|
39
|
+
# with the full concatenated frame set. All other events go to on_event.
|
|
40
|
+
def handle_event(event)
|
|
41
|
+
action_id = event.headers['ActionID']
|
|
42
|
+
result = append_to_buffer(action_id, event.raw)
|
|
43
|
+
case result
|
|
44
|
+
when :buffered
|
|
45
|
+
nil # keep accumulating until the Complete event
|
|
46
|
+
when nil
|
|
47
|
+
@on_event&.call(event)
|
|
48
|
+
else # completed: joined raw frames
|
|
49
|
+
resolve_promise(action_id, result)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def start_buffer(action_id, raw)
|
|
54
|
+
@promises_mutex.synchronize do
|
|
55
|
+
@buffers[action_id] = [raw] if @promises.key?(action_id)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns :buffered while accumulating, the joined raw when the list is
|
|
60
|
+
# complete, or nil when action_id has no active buffer.
|
|
61
|
+
def append_to_buffer(action_id, raw)
|
|
62
|
+
@promises_mutex.synchronize do
|
|
63
|
+
buffer = @buffers[action_id]
|
|
64
|
+
return nil unless buffer
|
|
65
|
+
|
|
66
|
+
buffer << raw
|
|
67
|
+
return :buffered unless list_terminator?(raw)
|
|
68
|
+
|
|
69
|
+
@buffers.delete(action_id).join
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def list_start?(headers)
|
|
74
|
+
headers && headers['EventList'].to_s.casecmp?('start')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def list_terminator?(raw)
|
|
78
|
+
raw.match?(/^EventList:\s*Complete/i) || raw.match?(/^Event:.*Complete\s*$/)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby-asterisk/ami/event'
|
|
4
|
+
|
|
5
|
+
module RubyAsterisk
|
|
6
|
+
module AMI
|
|
7
|
+
# Stateless module for parsing raw AMI byte streams into structured messages.
|
|
8
|
+
#
|
|
9
|
+
# All methods are class-level and side-effect free; they can be called from
|
|
10
|
+
# any thread or Fiber without synchronization.
|
|
11
|
+
module Parser
|
|
12
|
+
DELIMITER = "\r\n\r\n"
|
|
13
|
+
DELIMITER_LEN = DELIMITER.length
|
|
14
|
+
|
|
15
|
+
# Parses "Key: Value\r\n..." lines into a frozen hash.
|
|
16
|
+
# Lines without a colon separator (e.g. the AMI welcome banner) are skipped.
|
|
17
|
+
def self.parse_headers(raw)
|
|
18
|
+
headers = {}
|
|
19
|
+
raw.split(/\r?\n/).each do |line|
|
|
20
|
+
next if line.empty?
|
|
21
|
+
|
|
22
|
+
colon = line.index(':')
|
|
23
|
+
next unless colon&.positive?
|
|
24
|
+
|
|
25
|
+
headers[line[0, colon].freeze] = line[(colon + 1)..].strip.freeze
|
|
26
|
+
end
|
|
27
|
+
headers.freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Extracts all complete AMI messages from +buffer+ (mutates it) and
|
|
31
|
+
# yields a frozen message hash for each one.
|
|
32
|
+
#
|
|
33
|
+
# @param buffer [String] mutable accumulation buffer
|
|
34
|
+
# @yieldparam msg [Hash] frozen: { type: :response|:event, headers:, raw:, action_id: }
|
|
35
|
+
def self.drain(buffer)
|
|
36
|
+
while (idx = buffer.index(DELIMITER))
|
|
37
|
+
raw = buffer.slice!(0, idx + DELIMITER_LEN)
|
|
38
|
+
msg = build_message(raw)
|
|
39
|
+
yield msg if msg
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Builds a single frozen message hash from a raw AMI frame, or nil if the
|
|
44
|
+
# frame carries no recognised Response/Event header.
|
|
45
|
+
def self.build_message(raw)
|
|
46
|
+
headers = parse_headers(raw)
|
|
47
|
+
return nil if headers.empty?
|
|
48
|
+
|
|
49
|
+
frozen_raw = raw.freeze
|
|
50
|
+
if headers.key?('Response')
|
|
51
|
+
{ type: :response, headers: headers, raw: frozen_raw,
|
|
52
|
+
action_id: headers['ActionID'] }.freeze
|
|
53
|
+
elsif headers.key?('Event')
|
|
54
|
+
{ type: :event,
|
|
55
|
+
event: RubyAsterisk::AMI::Event.new(headers, frozen_raw) }.freeze
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'timeout'
|
|
4
|
+
require 'ruby-asterisk/response_builder'
|
|
5
|
+
|
|
6
|
+
module RubyAsterisk
|
|
7
|
+
module AMI
|
|
8
|
+
##
|
|
9
|
+
# Thread-safe promise representing a pending AMI command response.
|
|
10
|
+
#
|
|
11
|
+
# Created by {Client#execute} and resolved by the event-loop thread
|
|
12
|
+
# when the matching ActionID response arrives from Asterisk.
|
|
13
|
+
#
|
|
14
|
+
# Usage (blocking):
|
|
15
|
+
# promise = client.ping
|
|
16
|
+
# response = promise.value(5) # blocks up to 5 s, returns a Response
|
|
17
|
+
#
|
|
18
|
+
# Usage (async):
|
|
19
|
+
# promise = client.ping # returns immediately
|
|
20
|
+
# # ... do other work ...
|
|
21
|
+
# response = promise.value # blocks until resolved or timeout
|
|
22
|
+
#
|
|
23
|
+
class Promise
|
|
24
|
+
attr_reader :action_id
|
|
25
|
+
|
|
26
|
+
# Optional callback invoked when {#value} gives up on a timeout, used by
|
|
27
|
+
# the Reactor to drop the abandoned entry from its pending map.
|
|
28
|
+
attr_accessor :on_timeout
|
|
29
|
+
|
|
30
|
+
# @param action_id [String] the AMI ActionID this promise tracks
|
|
31
|
+
# @param command_type [String] the AMI action name (e.g. 'Ping')
|
|
32
|
+
# @param timeout [Numeric, nil] default seconds to wait in #value;
|
|
33
|
+
# nil waits indefinitely (used by WaitEvent with a negative Timeout)
|
|
34
|
+
def initialize(action_id:, command_type:, timeout: 5)
|
|
35
|
+
@action_id = action_id
|
|
36
|
+
@command_type = command_type
|
|
37
|
+
@timeout = timeout
|
|
38
|
+
@mutex = Mutex.new
|
|
39
|
+
@cv = ConditionVariable.new
|
|
40
|
+
@raw = nil
|
|
41
|
+
@error = nil
|
|
42
|
+
@resolved = false
|
|
43
|
+
@on_timeout = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolve the promise with raw AMI response data.
|
|
47
|
+
# Called by the Client event-loop thread — safe to call from any thread.
|
|
48
|
+
#
|
|
49
|
+
# @param raw [String] the raw AMI response string
|
|
50
|
+
def resolve(raw)
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
@raw = raw
|
|
53
|
+
@resolved = true
|
|
54
|
+
@cv.broadcast
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Reject the promise with an error.
|
|
59
|
+
# Called when the connection drops or the client disconnects.
|
|
60
|
+
#
|
|
61
|
+
# @param error [Exception]
|
|
62
|
+
def reject(error)
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
@error = error
|
|
65
|
+
@resolved = true
|
|
66
|
+
@cv.broadcast
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Block until the response arrives and return it as a {Response}.
|
|
71
|
+
#
|
|
72
|
+
# @param timeout [Numeric, nil] seconds to wait (defaults to the value set
|
|
73
|
+
# at construction); nil blocks until the response arrives
|
|
74
|
+
# @return [RubyAsterisk::Response]
|
|
75
|
+
# @raise [Timeout::Error] if no response arrives within +timeout+ seconds
|
|
76
|
+
# @raise [RuntimeError] if the promise was rejected (e.g. disconnect)
|
|
77
|
+
def value(timeout = @timeout)
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
unless @resolved
|
|
80
|
+
@cv.wait(@mutex, timeout)
|
|
81
|
+
unless @resolved
|
|
82
|
+
@on_timeout&.call
|
|
83
|
+
raise Timeout::Error,
|
|
84
|
+
"Timeout waiting for AMI response (ActionID: #{@action_id})"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
raise @error if @error
|
|
88
|
+
|
|
89
|
+
build_response
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Boolean] true if the promise has been resolved or rejected
|
|
94
|
+
def resolved?
|
|
95
|
+
@mutex.synchronize { @resolved }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def build_response
|
|
101
|
+
ResponseBuilder.new.tap do |builder|
|
|
102
|
+
builder.type = @command_type
|
|
103
|
+
builder.raw_response = @raw
|
|
104
|
+
end.build
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'ruby-asterisk/ami/parser'
|
|
5
|
+
require 'ruby-asterisk/ami/event_list_aggregation'
|
|
6
|
+
|
|
7
|
+
module RubyAsterisk
|
|
8
|
+
module AMI
|
|
9
|
+
# Hosts the AMI connection in two plain OS threads:
|
|
10
|
+
#
|
|
11
|
+
# writer_thread — blocks on Thread::Queue#pop and writes commands to the
|
|
12
|
+
# AMI socket. On :stop it closes the socket, which unblocks
|
|
13
|
+
# the reader thread's readpartial call.
|
|
14
|
+
#
|
|
15
|
+
# reader_thread — loops on socket.readpartial, parses AMI frames with Parser,
|
|
16
|
+
# resolves pending Promises or fires the on_event callback.
|
|
17
|
+
#
|
|
18
|
+
# Using plain Threads (no Fiber scheduler) ensures deterministic shutdown on
|
|
19
|
+
# all Ruby versions: closing an IO from one Thread immediately raises IOError
|
|
20
|
+
# in any other Thread blocked on that IO.
|
|
21
|
+
#
|
|
22
|
+
# Thread-safety contract:
|
|
23
|
+
# - External callers use #send_command, #register_promise,
|
|
24
|
+
# #reject_all_promises, #stop — all thread-safe.
|
|
25
|
+
# - Promise resolution via Mutex+CV (Promise class unchanged).
|
|
26
|
+
# - No cross-thread IO inside the reactor; one thread reads, one writes.
|
|
27
|
+
class Reactor
|
|
28
|
+
include EventListAggregation
|
|
29
|
+
|
|
30
|
+
def initialize(host, port, on_event: nil, on_disconnect: nil)
|
|
31
|
+
@host = host
|
|
32
|
+
@port = port
|
|
33
|
+
@on_event = on_event
|
|
34
|
+
@on_disconnect = on_disconnect
|
|
35
|
+
|
|
36
|
+
@command_queue = Thread::Queue.new
|
|
37
|
+
@promises_mutex = Mutex.new
|
|
38
|
+
@promises = {}
|
|
39
|
+
@buffers = {} # action_id => Array<raw frame> for in-flight EventList replies
|
|
40
|
+
@socket = nil
|
|
41
|
+
@writer_thread = nil
|
|
42
|
+
@reader_thread = nil
|
|
43
|
+
@stopping = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Open the socket, consume the AMI banner, and start both threads.
|
|
47
|
+
# Raises if the connection fails.
|
|
48
|
+
def start
|
|
49
|
+
@socket = TCPSocket.new(@host, @port)
|
|
50
|
+
@socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
|
|
51
|
+
@socket.gets # consume AMI banner ("Asterisk Call Manager/x.y\n")
|
|
52
|
+
|
|
53
|
+
@writer_thread = Thread.new { writer_loop }
|
|
54
|
+
@reader_thread = Thread.new { reader_loop }
|
|
55
|
+
self
|
|
56
|
+
rescue StandardError
|
|
57
|
+
close_socket
|
|
58
|
+
raise
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Send a raw AMI command string from any external thread.
|
|
62
|
+
def send_command(cmd)
|
|
63
|
+
@command_queue.push(cmd.freeze)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Register a Promise keyed by ActionID.
|
|
67
|
+
def register_promise(action_id, promise)
|
|
68
|
+
@promises_mutex.synchronize { @promises[action_id] = promise }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Remove a pending Promise without resolving it (e.g. after a caller
|
|
72
|
+
# timeout) so the pending map does not grow unbounded.
|
|
73
|
+
def unregister_promise(action_id)
|
|
74
|
+
@promises_mutex.synchronize do
|
|
75
|
+
@promises.delete(action_id)
|
|
76
|
+
@buffers.delete(action_id)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Reject all pending promises with the given error (thread-safe).
|
|
81
|
+
def reject_all_promises(error)
|
|
82
|
+
promises = @promises_mutex.synchronize do
|
|
83
|
+
@buffers.clear
|
|
84
|
+
@promises.values.tap { @promises.clear }
|
|
85
|
+
end
|
|
86
|
+
promises.each { |p| p.reject(error) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Stop both threads gracefully. Blocks until they exit.
|
|
90
|
+
def stop
|
|
91
|
+
return unless @writer_thread&.alive? || @reader_thread&.alive?
|
|
92
|
+
|
|
93
|
+
@stopping = true
|
|
94
|
+
@command_queue.push(:stop) # wakes writer_thread immediately
|
|
95
|
+
@writer_thread&.join(2)
|
|
96
|
+
@reader_thread&.join(2)
|
|
97
|
+
@writer_thread = nil
|
|
98
|
+
@reader_thread = nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Both loops bind the socket to a local before their first IO call:
|
|
104
|
+
# #close_socket nils out @socket, so reading the ivar inside the loop could
|
|
105
|
+
# raise NoMethodError on nil instead of the IOError the rescues expect.
|
|
106
|
+
# A closed IO object still raises IOError, which is the intended path.
|
|
107
|
+
def writer_loop
|
|
108
|
+
socket = @socket
|
|
109
|
+
loop do
|
|
110
|
+
cmd = @command_queue.pop
|
|
111
|
+
break if cmd == :stop
|
|
112
|
+
|
|
113
|
+
socket.write(cmd)
|
|
114
|
+
rescue IOError, SystemCallError
|
|
115
|
+
break # socket closed or peer reset — reader_loop handles caller notification
|
|
116
|
+
end
|
|
117
|
+
ensure
|
|
118
|
+
close_socket # raises IOError in reader_thread's readpartial
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def reader_loop
|
|
122
|
+
socket = @socket
|
|
123
|
+
buffer = +''
|
|
124
|
+
loop do
|
|
125
|
+
chunk = socket.readpartial(4096)
|
|
126
|
+
buffer << chunk
|
|
127
|
+
Parser.drain(buffer) { |msg| dispatch(msg) }
|
|
128
|
+
end
|
|
129
|
+
rescue EOFError
|
|
130
|
+
handle_unexpected_disconnect('Disconnected: EOF from server') unless @stopping
|
|
131
|
+
rescue IOError, Errno::EBADF
|
|
132
|
+
handle_unexpected_disconnect('Disconnected') unless @stopping
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
# A deliberate #stop must never surface as a disconnect: the caller is
|
|
135
|
+
# notified by Client#disconnect, which rejects with its own message.
|
|
136
|
+
handle_unexpected_disconnect("Reactor read error: #{e.message}") unless @stopping
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Notify the client and fail every blocked caller when the link drops
|
|
140
|
+
# without an explicit #stop request.
|
|
141
|
+
def handle_unexpected_disconnect(message)
|
|
142
|
+
@on_disconnect&.call
|
|
143
|
+
reject_all_promises(RuntimeError.new(message))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def resolve_promise(action_id, raw_data)
|
|
147
|
+
promise = @promises_mutex.synchronize do
|
|
148
|
+
@buffers.delete(action_id)
|
|
149
|
+
@promises.delete(action_id)
|
|
150
|
+
end
|
|
151
|
+
promise&.resolve(raw_data)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def close_socket
|
|
155
|
+
@socket&.close
|
|
156
|
+
@socket = nil
|
|
157
|
+
rescue StandardError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|