ruby-asterisk 0.2.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 +48 -4
- data/.rubocop.yml +84 -0
- data/CHANGELOG.md +52 -0
- data/Gemfile +3 -1
- data/README.md +410 -108
- data/Rakefile +3 -5
- 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 +71 -69
- data/lib/ruby-asterisk/request.rb +33 -14
- data/lib/ruby-asterisk/response.rb +43 -16
- data/lib/ruby-asterisk/response_builder.rb +18 -0
- data/lib/ruby-asterisk/response_parser.rb +10 -9
- data/lib/ruby-asterisk/version.rb +4 -2
- data/lib/ruby-asterisk.rb +5 -222
- data/ruby-asterisk.gemspec +23 -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 +46 -47
- data/spec/spec_helper.rb +12 -0
- data/spec/support/mock_ami_server.rb +50 -0
- data/spec/support/mock_ari_websocket_server.rb +114 -0
- metadata +138 -18
- data/CHANGELOG +0 -3
- data/spec/ruby-asterisk/ruby_asterisk_spec.rb +0 -192
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c95a048e0d2735b23389820940e402ff7a48a3f50e4ec2a6f02d4bdd7b4fc202
|
|
4
|
+
data.tar.gz: cd6aa33ec274995602082c1c78a352c6ce32e979b402c35e62efcf728425fb2d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4d6f5a3be79a8dcc25109ca1baf8d2844a72086a16eae470c136c3d36e6b5e9cba658b4d877a8e5f7693b8b8d7cc75e71ad0552ddb8d53ef6239ee25d07115c8
|
|
7
|
+
data.tar.gz: 0c97e244ad05079470ce0067d58fcc557dfaff420343ad79b6db853a2a865d233dff09e98df2c12631ce6d9dd0b3f7885cad6eb630f182d83902a0840091d273
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ruby-asterisk-agi
|
|
3
|
+
description: Build AGI/FastAGI dialplan logic in Ruby with ruby-asterisk: Server (Async+Fiber), Session command verbs, Protocol parsing, IVR patterns, 520 multi-line error handling.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ruby-asterisk AGI / FastAGI
|
|
7
|
+
|
|
8
|
+
## When to use this skill
|
|
9
|
+
|
|
10
|
+
Use this skill when writing Ruby code that Asterisk calls via `AGI(agi://host:port)`. ruby-asterisk provides a FastAGI TCP server (`AGI::Server`) backed by the `async` gem's Fiber scheduler, plus a `Session` class with the full set of AGI command verbs.
|
|
11
|
+
|
|
12
|
+
## Core concepts
|
|
13
|
+
|
|
14
|
+
### Architecture
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
Asterisk ruby-asterisk FastAGI server
|
|
18
|
+
| |
|
|
19
|
+
|--- TCP connect -----> AGI::Server (TCPServer + Async Sync)
|
|
20
|
+
| |
|
|
21
|
+
| spawns Fiber per connection
|
|
22
|
+
| |
|
|
23
|
+
|--- ENV block -------> Session#read_env (parse agi_* vars)
|
|
24
|
+
|--- CMD response <---- Session#<verb> (write command, read result)
|
|
25
|
+
|--- CMD response <---- ...
|
|
26
|
+
|--- EOF / hangup ----> Fiber exits, socket closed
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Each connection runs in its own **Fiber** sharing one OS thread. Socket reads/writes yield the Fiber rather than blocking, so thousands of simultaneous calls can be handled efficiently.
|
|
30
|
+
|
|
31
|
+
### Session execute model
|
|
32
|
+
|
|
33
|
+
Every command method writes a line to the socket and reads back a response:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# Low-level (rarely called directly):
|
|
37
|
+
response = session.execute('ANSWER')
|
|
38
|
+
# => { code: 200, result: "0", data: nil }
|
|
39
|
+
|
|
40
|
+
# High-level convenience methods:
|
|
41
|
+
session.answer # => { code: 200, result: "0", data: nil }
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`execute` raises `RubyAsterisk::Error` on:
|
|
45
|
+
- EOF from Asterisk (`"Connection closed by Asterisk"`)
|
|
46
|
+
- Any 5xx response, after collecting the full `520-…/520 End of proper usage.` multi-line error body
|
|
47
|
+
|
|
48
|
+
### Protocol module
|
|
49
|
+
|
|
50
|
+
`RubyAsterisk::AGI::Protocol` is a stateless utility module (all class methods):
|
|
51
|
+
|
|
52
|
+
| Method | Purpose |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `format_command(cmd, *args)` | Build a `"CMD arg1 arg2\n"` string with argument escaping |
|
|
55
|
+
| `escape_argument(arg)` | Pass through bare words; double-quote strings with spaces/special chars |
|
|
56
|
+
| `quote(arg)` | **Always** double-quote (use for filenames, variable values, messages) |
|
|
57
|
+
| `parse_response(line)` | Parse `"200 result=0 (data)"` → `{code:, result:, data:}` frozen Hash |
|
|
58
|
+
| `parse_env_line(line)` | Parse one `"agi_key: value"` line → `[key, value]` or `nil` |
|
|
59
|
+
| `parse_env_block(io, &block)` | Read env lines until blank, return `Hash{String=>String}` |
|
|
60
|
+
| `collect_multiline_error(first_line, first_resp, io)` | Drain 520 continuation lines |
|
|
61
|
+
| `error?(response)` | `response[:code] >= 500` |
|
|
62
|
+
|
|
63
|
+
**`quote` vs `escape_argument`**: use `quote` for filenames, variable values, and text that Asterisk requires always quoted; use `escape_argument` for digit-escape lists and simple identifiers that only need quoting when they contain spaces or special characters.
|
|
64
|
+
|
|
65
|
+
## FastAGI server
|
|
66
|
+
|
|
67
|
+
### Minimal setup
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
require 'ruby-asterisk'
|
|
71
|
+
|
|
72
|
+
server = RubyAsterisk::AGI::Server.new('0.0.0.0', 4573)
|
|
73
|
+
server.handle do |session|
|
|
74
|
+
session.answer
|
|
75
|
+
session.stream_file('hello-world')
|
|
76
|
+
session.hangup
|
|
77
|
+
end
|
|
78
|
+
server.run # blocks until server.stop is called
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
In Asterisk `extensions.conf`:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
exten => 100,1,AGI(agi://192.168.1.100:4573)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Server API
|
|
88
|
+
|
|
89
|
+
| Method | Signature | Notes |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `new` | `(host, port, logger: Logger.new($stdout))` | `port: 0` → ephemeral |
|
|
92
|
+
| `handle` | `(&block)` | Must be called before `run`; returns `self` |
|
|
93
|
+
| `run` | — | Blocks; raises `Error` if no handler |
|
|
94
|
+
| `stop` | — | Closes listener; active sessions finish naturally |
|
|
95
|
+
| `running?` | — | Boolean |
|
|
96
|
+
| `port` | — | Actual bound port (useful when `port: 0`) |
|
|
97
|
+
|
|
98
|
+
## Session command verbs
|
|
99
|
+
|
|
100
|
+
All methods return `{ code: Integer, result: String|nil, data: String|nil }`.
|
|
101
|
+
|
|
102
|
+
### Lifecycle and environment
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
session.read_env # parse initial agi_* block; fills session.env
|
|
106
|
+
session.env # => { "agi_channel" => "SIP/alice-001", "agi_callerid" => "...", ... }
|
|
107
|
+
session.execute(cmd) # low-level: write cmd, read one response line
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Call control
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
session.answer
|
|
114
|
+
session.hangup
|
|
115
|
+
session.hangup('SIP/alice-001') # explicit channel
|
|
116
|
+
session.channel_status # status of current channel (returns numeric code in result)
|
|
117
|
+
session.channel_status('SIP/b') # explicit channel
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Audio playback
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
session.stream_file('hello-world') # play sound; no interrupt
|
|
124
|
+
session.stream_file('hello-world', '1234') # play; stop on digit 1/2/3/4
|
|
125
|
+
session.stream_file('hello-world', '#*') # stop on # or *
|
|
126
|
+
|
|
127
|
+
session.say_digits('12345')
|
|
128
|
+
session.say_digits('12345', '#') # stop on #
|
|
129
|
+
|
|
130
|
+
session.say_number(42)
|
|
131
|
+
session.say_number(42, '#')
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### DTMF / input collection
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
result = session.get_data('enter-pin', timeout: 5000, max_digits: 4)
|
|
138
|
+
puts result[:data] # => "1234" (digits entered) or "" on timeout
|
|
139
|
+
|
|
140
|
+
digit_ascii = session.wait_for_digit(5000) # returns ASCII int, -1 on timeout
|
|
141
|
+
char = digit_ascii.chr if digit_ascii > 0
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Variables
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
session.set_variable('MY_VAR', 'hello world')
|
|
148
|
+
response = session.get_variable('MY_VAR')
|
|
149
|
+
puts response[:data] # => "hello world"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Recording
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
session.record_file(
|
|
156
|
+
'/tmp/voicemail', # filename (no extension)
|
|
157
|
+
format: 'wav', # default: 'wav'
|
|
158
|
+
escape_digits: '#', # stop on # (default)
|
|
159
|
+
timeout_ms: 30_000, # -1 = no timeout (default)
|
|
160
|
+
offset: 0,
|
|
161
|
+
beep: true, # play beep before recording (default)
|
|
162
|
+
silence: 3 # stop after 3 s of silence (optional)
|
|
163
|
+
)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Dialplan application execution
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
session.exec('Playback', 'hello-world')
|
|
170
|
+
session.exec('AGI', 'script.agi')
|
|
171
|
+
session.dial('PJSIP/bob', timeout: 30, options: 'r') # wraps Exec Dial
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### AstDB operations
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
session.database_put('myapp', 'last_caller', '0391234567')
|
|
178
|
+
response = session.database_get('myapp', 'last_caller')
|
|
179
|
+
puts response[:data] # => "0391234567"
|
|
180
|
+
session.database_del('myapp', 'last_caller')
|
|
181
|
+
session.database_deltree('myapp') # delete whole family
|
|
182
|
+
session.database_deltree('myapp', 'sub/tree') # delete sub-tree
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Utilities
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
session.verbose('Starting IVR', level: 2) # log to Asterisk CLI
|
|
189
|
+
session.send_text('Hello from FastAGI')
|
|
190
|
+
session.send_image('logo.png')
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Patterns
|
|
194
|
+
|
|
195
|
+
### Pattern 1: Minimal FastAGI (answer → play → hangup)
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
server = RubyAsterisk::AGI::Server.new('0.0.0.0', 4573)
|
|
199
|
+
server.handle { |s| s.answer; s.stream_file('hello-world'); s.hangup }
|
|
200
|
+
server.run
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Pattern 2: IVR with DTMF branching
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
server = RubyAsterisk::AGI::Server.new('0.0.0.0', 4573)
|
|
207
|
+
server.handle do |session|
|
|
208
|
+
session.answer
|
|
209
|
+
|
|
210
|
+
result = session.get_data('ivr-welcome', timeout: 5000, max_digits: 1)
|
|
211
|
+
choice = result[:data].to_s.strip
|
|
212
|
+
|
|
213
|
+
case choice
|
|
214
|
+
when '1'
|
|
215
|
+
session.dial('PJSIP/sales', timeout: 30)
|
|
216
|
+
when '2'
|
|
217
|
+
session.dial('PJSIP/support', timeout: 30)
|
|
218
|
+
when '0'
|
|
219
|
+
session.exec('VoiceMail', '1000@default')
|
|
220
|
+
else
|
|
221
|
+
session.stream_file('invalid-option')
|
|
222
|
+
session.hangup
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
server.run
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Pattern 3: Recording with error handling
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
server = RubyAsterisk::AGI::Server.new('0.0.0.0', 4573)
|
|
232
|
+
server.handle do |session|
|
|
233
|
+
session.answer
|
|
234
|
+
session.stream_file('record-after-beep')
|
|
235
|
+
|
|
236
|
+
begin
|
|
237
|
+
session.record_file('/tmp/greeting',
|
|
238
|
+
format: 'wav',
|
|
239
|
+
escape_digits: '#',
|
|
240
|
+
timeout_ms: 30_000,
|
|
241
|
+
beep: true,
|
|
242
|
+
silence: 3)
|
|
243
|
+
|
|
244
|
+
session.database_put('greetings', 'latest', '/tmp/greeting.wav')
|
|
245
|
+
session.stream_file('your-recording-has-been-saved')
|
|
246
|
+
rescue RubyAsterisk::Error => e
|
|
247
|
+
# e.message contains the full 520 multi-line error body if applicable
|
|
248
|
+
session.verbose("Recording failed: #{e.message}", level: 1)
|
|
249
|
+
session.stream_file('an-error-occurred')
|
|
250
|
+
ensure
|
|
251
|
+
session.hangup
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
server.run
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Pattern 4: All verbs — quick reference snippets
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
# ---- Call control ----
|
|
261
|
+
session.answer # ANSWER
|
|
262
|
+
session.hangup # HANGUP
|
|
263
|
+
session.hangup('SIP/channel-001') # HANGUP <channel>
|
|
264
|
+
session.channel_status # CHANNEL STATUS
|
|
265
|
+
session.channel_status('SIP/channel-001') # CHANNEL STATUS <channel>
|
|
266
|
+
|
|
267
|
+
# ---- Playback ----
|
|
268
|
+
session.stream_file('hello-world', '') # STREAM FILE "hello-world" ""
|
|
269
|
+
session.say_digits('123', '#') # SAY DIGITS 123 "#"
|
|
270
|
+
session.say_number(42, '') # SAY NUMBER 42 ""
|
|
271
|
+
|
|
272
|
+
# ---- Input ----
|
|
273
|
+
session.get_data('enter-pin', timeout: 5000, max_digits: 4) # GET DATA "enter-pin" 5000 4
|
|
274
|
+
session.wait_for_digit(5000) # WAIT FOR DIGIT 5000
|
|
275
|
+
|
|
276
|
+
# ---- Variables ----
|
|
277
|
+
session.set_variable('FOO', 'bar baz') # SET VARIABLE FOO "bar baz"
|
|
278
|
+
session.get_variable('FOO') # GET VARIABLE FOO
|
|
279
|
+
|
|
280
|
+
# ---- Recording ----
|
|
281
|
+
session.record_file('/tmp/rec', format: 'wav', beep: true, silence: 3)
|
|
282
|
+
|
|
283
|
+
# ---- Execution ----
|
|
284
|
+
session.exec('Playback', 'hello-world') # EXEC Playback "hello-world"
|
|
285
|
+
session.dial('PJSIP/alice', timeout: 30) # EXEC Dial "PJSIP/alice,30"
|
|
286
|
+
session.verbose('log me', level: 1) # VERBOSE "log me" 1
|
|
287
|
+
|
|
288
|
+
# ---- Text / image ----
|
|
289
|
+
session.send_text('Hello') # SEND TEXT "Hello"
|
|
290
|
+
session.send_image('logo.png') # SEND IMAGE logo.png
|
|
291
|
+
|
|
292
|
+
# ---- AstDB ----
|
|
293
|
+
session.database_put('family', 'key', 'value') # DATABASE PUT family key "value"
|
|
294
|
+
session.database_get('family', 'key') # DATABASE GET family key
|
|
295
|
+
session.database_del('family', 'key') # DATABASE DEL family key
|
|
296
|
+
session.database_deltree('family') # DATABASE DELTREE family
|
|
297
|
+
session.database_deltree('family', 'sub') # DATABASE DELTREE family sub
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Gotchas
|
|
301
|
+
|
|
302
|
+
- **`handle` is mandatory** before `run`; calling `run` without it raises `RubyAsterisk::Error`.
|
|
303
|
+
- **`execute` raises on EOF** (`"Connection closed by Asterisk"`) and on any 5xx, including `520-…` multi-line errors. All command methods propagate this.
|
|
304
|
+
- **`Protocol.quote` vs `escape_argument`**: always use `quote` for filenames, variable values, and text content. Use `escape_argument` for digit-escape strings or other tokens that only need quoting conditionally.
|
|
305
|
+
- **Ruby 3.1**: an `IO#timeout` / `IO#timeout=` shim is applied at load time in `agi/server.rb`. Required because the `async` gem calls `io.timeout` inside its scheduler on Ruby 3.2+ — the shim returns `nil` on 3.1 to disable that path. Never remove it while Ruby 3.1 is in the support matrix.
|
|
306
|
+
- **CPU-bound work in handlers** will block the shared Fiber scheduler. Offload to a Thread or separate process if you need heavy computation per call.
|
|
307
|
+
- **`session.env` is populated by `read_env`** which is called automatically by `Server#serve`. In tests, call `session.read_env` manually after writing the env block to the socket.
|
|
308
|
+
- The `logger` on `Server` is passed down to each `Session`; unknown AGI env lines (not matching `agi_*:`) are logged at `debug` level.
|
|
309
|
+
|
|
310
|
+
## Source files
|
|
311
|
+
|
|
312
|
+
- `lib/ruby-asterisk/agi/server.rb` — `Server` class + `IO#timeout` shim
|
|
313
|
+
- `lib/ruby-asterisk/agi/session.rb` — `Session` class
|
|
314
|
+
- `lib/ruby-asterisk/agi/protocol.rb` — `Protocol` module
|
|
315
|
+
- `spec/ruby-asterisk/agi/server_spec.rb` — server lifecycle + Fiber concurrency proof
|
|
316
|
+
- `spec/ruby-asterisk/agi/session_spec.rb` — all command verbs, error handling
|
|
317
|
+
- `spec/ruby-asterisk/agi/protocol_spec.rb` — parsing and formatting helpers
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ruby-asterisk-ami
|
|
3
|
+
description: Build an async Asterisk AMI client with ruby-asterisk: connect, login, Promise-based commands (channel, queue, conference, SIP, mailbox, monitor, extension state).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ruby-asterisk AMI Client
|
|
7
|
+
|
|
8
|
+
## When to use this skill
|
|
9
|
+
|
|
10
|
+
Use this skill when writing code that connects to the Asterisk Manager Interface via TCP — for example: originating calls, checking extension state, managing queues, monitoring channels, or handling SIP peers.
|
|
11
|
+
|
|
12
|
+
## Core concepts
|
|
13
|
+
|
|
14
|
+
### Every command returns a Promise
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
promise = ami.ping # returns RubyAsterisk::AMI::Promise immediately
|
|
18
|
+
response = promise.value # blocks up to 5 s (default timeout)
|
|
19
|
+
response = promise.value(10) # custom timeout in seconds
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`#value` raises `Timeout::Error` if no response arrives within the timeout.
|
|
23
|
+
`disconnect` rejects all pending promises with `RuntimeError`.
|
|
24
|
+
|
|
25
|
+
### The Response object
|
|
26
|
+
|
|
27
|
+
| Attribute | Type | Notes |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `success` | Boolean | `true` when raw contains `Response: Success` |
|
|
30
|
+
| `action_id` | String | echoed ActionID |
|
|
31
|
+
| `message` | String | `Message:` field |
|
|
32
|
+
| `data` | Hash / Array | structured, present when action is in `PARSE_DATA` |
|
|
33
|
+
| `raw_response` | String/Array | unprocessed AMI text |
|
|
34
|
+
| `type` | String | AMI action name |
|
|
35
|
+
|
|
36
|
+
### Connection lifecycle
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
ami = RubyAsterisk::AMI::Client.new(host: '192.168.1.1', port: 5038)
|
|
40
|
+
ami.connect # starts the reader + writer threads
|
|
41
|
+
ami.login(username: 'admin', secret: 'secret').value
|
|
42
|
+
# ... use commands ...
|
|
43
|
+
ami.logoff.value
|
|
44
|
+
ami.disconnect # stops both threads, rejects pending promises
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`login` auto-calls `connect` if not already connected.
|
|
48
|
+
|
|
49
|
+
## API reference
|
|
50
|
+
|
|
51
|
+
### System
|
|
52
|
+
|
|
53
|
+
| Method | AMI Action | Notes |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `ping` | `Ping` | Returns `Pong` response |
|
|
56
|
+
| `command(cmd)` | `Command` | CLI passthrough, e.g. `'core show channels'` |
|
|
57
|
+
| `wait_event(timeout: -1)` | `WaitEvent` | negative = wait forever, so `#value` has no deadline; a positive value bounds it (floor: the client default) |
|
|
58
|
+
| `event_mask(mask = 'off')` | `Events` | `'on'` / `'off'` / `'system,call'` |
|
|
59
|
+
| `parked_calls` | `ParkedCalls` | |
|
|
60
|
+
| `device_state_list` | `DeviceStateList` | |
|
|
61
|
+
| `skinny_devices` | `SKINNYdevices` | |
|
|
62
|
+
| `skinny_lines` | `SKINNYlines` | |
|
|
63
|
+
|
|
64
|
+
### Channel
|
|
65
|
+
|
|
66
|
+
| Method | Signature | AMI Action |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `core_show_channels` | — | `CoreShowChannels` |
|
|
69
|
+
| `status` | `(channel: nil, action_id: nil)` | `Status` |
|
|
70
|
+
| `originate` | `(channel, context, callee, priority, variable: nil, caller_id: nil, timeout: 30_000, async: nil)` | `Originate` |
|
|
71
|
+
| `originate_app` | `(channel:, application:, data:, async:)` | `Originate` (Application/Data form) |
|
|
72
|
+
| `redirect` | `(channel, context, callee, priority, variable: nil, caller_id: nil, timeout: 30_000)` | `Redirect` |
|
|
73
|
+
| `hangup` | `(channel)` | `Hangup` |
|
|
74
|
+
| `atxfer` | `(channel:, exten:, context:, priority: '1')` | `Atxfer` |
|
|
75
|
+
|
|
76
|
+
### Queue
|
|
77
|
+
|
|
78
|
+
| Method | Signature |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `queues` | — |
|
|
81
|
+
| `queue_status` | — |
|
|
82
|
+
| `queue_summary` | `(queue)` |
|
|
83
|
+
| `queue_add` | `(queue, interface, penalty: 2, paused: false, member_name: '')` |
|
|
84
|
+
| `queue_remove` | `(queue:, interface:)` |
|
|
85
|
+
| `queue_pause` | `(interface:, paused:, queue:, reason: 'none')` |
|
|
86
|
+
|
|
87
|
+
### Conference
|
|
88
|
+
|
|
89
|
+
| Method | Signature |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `meet_me_list` | — |
|
|
92
|
+
| `confbridges` | — |
|
|
93
|
+
| `confbridge` | `(conference)` |
|
|
94
|
+
| `confbridge_mute` | `(conference:, channel:)` |
|
|
95
|
+
| `confbridge_unmute` | `(conference:, channel:)` |
|
|
96
|
+
| `confbridge_kick` | `(conference:, channel:)` |
|
|
97
|
+
|
|
98
|
+
### Mailbox
|
|
99
|
+
|
|
100
|
+
| Method | Signature |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `mailbox_status` | `(mailbox:, context: 'default')` |
|
|
103
|
+
| `mailbox_count` | `(mailbox:, context: 'default')` |
|
|
104
|
+
|
|
105
|
+
### SIP
|
|
106
|
+
|
|
107
|
+
| Method | Signature |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `sip_peers` | — |
|
|
110
|
+
| `sip_show_peer` | `(peer)` |
|
|
111
|
+
| `sip_show_registry` | — |
|
|
112
|
+
|
|
113
|
+
### Extension
|
|
114
|
+
|
|
115
|
+
| Method | Signature | Notes |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `extension_state` | `(exten:, context:, action_id: nil)` | `data[:hints]` decorated with `DescriptiveStatus` |
|
|
118
|
+
|
|
119
|
+
### Monitor
|
|
120
|
+
|
|
121
|
+
| Method | Signature |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `monitor` | `(channel, mix: false, file: nil, format: 'wav')` |
|
|
124
|
+
| `stop_monitor` | `(channel)` |
|
|
125
|
+
| `pause_monitor` | `(channel)` |
|
|
126
|
+
| `unpause_monitor` | `(channel)` |
|
|
127
|
+
| `change_monitor` | `(channel:, file:)` |
|
|
128
|
+
|
|
129
|
+
## Usage patterns
|
|
130
|
+
|
|
131
|
+
### Minimal connect + command
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
ami = RubyAsterisk::AMI::Client.new(host: '127.0.0.1', port: 5038)
|
|
135
|
+
ami.login(username: 'admin', secret: 'secret').value
|
|
136
|
+
response = ami.ping.value
|
|
137
|
+
puts response.success # => true
|
|
138
|
+
ami.disconnect
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Originate and wait for result
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
promise = ami.originate('PJSIP/alice', 'outbound', '0391234567', '1', async: true)
|
|
145
|
+
response = promise.value(30)
|
|
146
|
+
puts response.success
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Poll queue status
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
loop do
|
|
153
|
+
members = ami.queue_status.value.data
|
|
154
|
+
members&.each { |m| puts m.inspect }
|
|
155
|
+
sleep 5
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Handle Timeout::Error
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
begin
|
|
163
|
+
response = ami.originate('SIP/slow', 'default', '100', '1').value(2)
|
|
164
|
+
rescue Timeout::Error
|
|
165
|
+
puts 'AMI did not respond in time'
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Gotchas
|
|
170
|
+
|
|
171
|
+
- **ActionID is auto-generated** (monotonic nanosecond clock in base36 plus an atomic counter). A caller-supplied `action_id:` replaces it — including in the `Promise` used for correlation — so it must be unique among in-flight commands.
|
|
172
|
+
- **Call `event_mask('on')`** on the connection if you need Asterisk to push async events (e.g. `StasisStart`). Off by default. Note that `AMI::Client` currently discards received events: there is no public subscription API yet.
|
|
173
|
+
- **`timeout:` is per command** and never mutates the client-wide default; `wait_event(timeout: -1)` builds a promise with no deadline, so bound it with `value(seconds)` if you must not block forever.
|
|
174
|
+
- **`disconnect` rejects all pending promises** — check for `RuntimeError` if you disconnect mid-flight.
|
|
175
|
+
- **`PARSE_DATA`** (`lib/ruby-asterisk/parsing_constants.rb`) controls which responses get structured `data`; unlisted actions return the raw string.
|
|
176
|
+
- The version constant is now `RubyAsterisk::VERSION` (was `Rami::VERSION` before 1.0.0).
|
|
177
|
+
|
|
178
|
+
## Source files
|
|
179
|
+
|
|
180
|
+
- `lib/ruby-asterisk/ami/client.rb` — `Client` class
|
|
181
|
+
- `lib/ruby-asterisk/ami/reactor.rb` — reader/writer threads owning the socket
|
|
182
|
+
- `lib/ruby-asterisk/ami/parser.rb` — stateless AMI frame parser
|
|
183
|
+
- `lib/ruby-asterisk/ami/promise.rb` — `Promise` class
|
|
184
|
+
- `lib/ruby-asterisk/ami/commands/*.rb` — command modules
|
|
185
|
+
- `lib/ruby-asterisk/response.rb` — `Response` class
|
|
186
|
+
- `lib/ruby-asterisk/parsing_constants.rb` — `PARSE_DATA`, `DESCRIPTIVE_STATUS`
|
|
187
|
+
- `spec/ruby-asterisk/ami/async_client_spec.rb` — integration tests
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ruby-asterisk-ari
|
|
3
|
+
description: Use the Asterisk REST Interface with ruby-asterisk: channels, bridges, playbacks, endpoints via HTTP, and RubyAsterisk::Error for 4xx/5xx handling.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ruby-asterisk ARI HTTP Client
|
|
7
|
+
|
|
8
|
+
## When to use this skill
|
|
9
|
+
|
|
10
|
+
Use this skill when writing code that controls Asterisk channels or bridges via the ARI REST API — for example: answering calls from a Stasis application, mixing channels into a bridge, controlling media playback, or inspecting endpoint registration state.
|
|
11
|
+
|
|
12
|
+
## Core concepts
|
|
13
|
+
|
|
14
|
+
- The client is **synchronous** (backed by Faraday). Every method blocks and returns the parsed JSON body (Hash or Array) or raises `RubyAsterisk::Error`.
|
|
15
|
+
- Resources are accessed via `Collection` objects (`client.channels`, `client.bridges`, etc.); each collection supports `list` and `get(id)`.
|
|
16
|
+
- Individual resource objects have a `data` Hash with the full JSON representation, plus convenience methods for actions.
|
|
17
|
+
- **`app_name`** must match the Stasis application declared in Asterisk's `ari.conf`; mismatches cause channels to be rejected or events to be silently dropped.
|
|
18
|
+
|
|
19
|
+
## Initialize
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require 'ruby-asterisk'
|
|
23
|
+
|
|
24
|
+
client = RubyAsterisk::ARI::Client.new(
|
|
25
|
+
'http://192.168.1.1:8088', # ARI base URL (no trailing slash)
|
|
26
|
+
'my_api_key', # API key used as HTTP Basic auth username
|
|
27
|
+
'my_stasis_app' # Stasis application name
|
|
28
|
+
)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## API reference
|
|
32
|
+
|
|
33
|
+
### Asterisk info
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
info = client.asterisk_info
|
|
37
|
+
# => Hash with keys "build", "system", "config", "status"
|
|
38
|
+
puts info['system']['version']
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Channels — `client.channels`
|
|
42
|
+
|
|
43
|
+
| Method | Signature | Returns |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `list` | — | Array of `Channel` |
|
|
46
|
+
| `get` | `(id)` | `Channel` |
|
|
47
|
+
|
|
48
|
+
**Channel actions:**
|
|
49
|
+
|
|
50
|
+
| Method | Notes |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `channel.id` | Channel identifier string |
|
|
53
|
+
| `channel.data` | Full JSON Hash from ARI |
|
|
54
|
+
| `channel.ring` | Send ringing indication |
|
|
55
|
+
| `channel.answer` | Answer the channel |
|
|
56
|
+
| `channel.play(media, **opts)` | Play audio; `media` = `'sound:hello-world'` |
|
|
57
|
+
| `channel.hangup` | Hang up and destroy |
|
|
58
|
+
|
|
59
|
+
### Bridges — `client.bridges`
|
|
60
|
+
|
|
61
|
+
| Method | Signature | Returns |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `list` | — | Array of `Bridge` |
|
|
64
|
+
| `get` | `(id)` | `Bridge` |
|
|
65
|
+
|
|
66
|
+
**Bridge actions:**
|
|
67
|
+
|
|
68
|
+
| Method | Notes |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `bridge.id` | Bridge identifier |
|
|
71
|
+
| `bridge.data` | Full JSON Hash |
|
|
72
|
+
| `bridge.add_channel(channel_id)` | Mix a channel into the bridge |
|
|
73
|
+
| `bridge.remove_channel(channel_id)` | Remove a channel |
|
|
74
|
+
| `bridge.destroy` | Destroy the bridge |
|
|
75
|
+
|
|
76
|
+
### Playbacks — `client.playbacks`
|
|
77
|
+
|
|
78
|
+
| Method | Signature | Returns |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `get` | `(id)` | `Playback` |
|
|
81
|
+
|
|
82
|
+
**Playback actions:**
|
|
83
|
+
|
|
84
|
+
| Method | Notes |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `playback.control('pause')` | Pause playback |
|
|
87
|
+
| `playback.control('unpause')` | Resume |
|
|
88
|
+
| `playback.control('restart')` | Restart from beginning |
|
|
89
|
+
| `playback.stop` | Stop and destroy |
|
|
90
|
+
|
|
91
|
+
### Endpoints — `client.endpoints`
|
|
92
|
+
|
|
93
|
+
| Method | Signature | Returns |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `list` | — | Array of `Endpoint` |
|
|
96
|
+
|
|
97
|
+
**Endpoint attributes:**
|
|
98
|
+
|
|
99
|
+
| Method | Notes |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `ep.technology` | e.g. `'PJSIP'`, `'SIP'` |
|
|
102
|
+
| `ep.resource` | e.g. `'alice'` |
|
|
103
|
+
| `ep.state` | `'online'` / `'offline'` |
|
|
104
|
+
| `ep.data` | Full JSON Hash |
|
|
105
|
+
|
|
106
|
+
## Error handling
|
|
107
|
+
|
|
108
|
+
All methods raise `RubyAsterisk::Error` for HTTP 4xx/5xx responses:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
begin
|
|
112
|
+
channel = client.channels.get('nonexistent')
|
|
113
|
+
rescue RubyAsterisk::Error => e
|
|
114
|
+
puts e.message # => "Channel not found"
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The error message comes from the JSON `message` field if present, otherwise from the HTTP reason phrase.
|
|
119
|
+
|
|
120
|
+
## Usage patterns
|
|
121
|
+
|
|
122
|
+
### Answer a Stasis channel and bridge two callers
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
channel_a = client.channels.get(event_channel_a_id)
|
|
126
|
+
channel_b = client.channels.get(event_channel_b_id)
|
|
127
|
+
|
|
128
|
+
bridge = client.bridges.list.first || begin
|
|
129
|
+
# bridges.list returns all existing bridges; create via ARI REST directly if none
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
channel_a.answer
|
|
133
|
+
channel_b.answer
|
|
134
|
+
|
|
135
|
+
bridge.add_channel(channel_a.id)
|
|
136
|
+
bridge.add_channel(channel_b.id)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Play audio and track playback
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
channel.play('sound:tt-monkeys', playbackId: 'pb-greeting')
|
|
143
|
+
pb = client.playbacks.get('pb-greeting')
|
|
144
|
+
pb.control('pause')
|
|
145
|
+
sleep 2
|
|
146
|
+
pb.control('unpause')
|
|
147
|
+
pb.stop
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### List all online PJSIP endpoints
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
online = client.endpoints.list.select { |ep| ep.technology == 'PJSIP' && ep.state == 'online' }
|
|
154
|
+
online.each { |ep| puts ep.resource }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Gotchas
|
|
158
|
+
|
|
159
|
+
- **Basic auth**: `api_key` is the **username**; the password is always empty (`''`). Check `ari.conf` if you get 401 errors.
|
|
160
|
+
- **`app_name` must match** the Stasis application configured in Asterisk; channels not subscribed to your app will not deliver events to the WebSocket client.
|
|
161
|
+
- **JSON parse fallback**: if the response body is not valid JSON, the raw string is returned instead of a Hash. This can happen with some error responses.
|
|
162
|
+
- **`client.channels.list`** returns an empty array (not nil) when no channels are active.
|
|
163
|
+
|
|
164
|
+
## Source files
|
|
165
|
+
|
|
166
|
+
- `lib/ruby-asterisk/ari/client.rb` — `Client` class
|
|
167
|
+
- `lib/ruby-asterisk/ari/resources/channel.rb` — `Channel` resource
|
|
168
|
+
- `lib/ruby-asterisk/ari/resources/bridge.rb` — `Bridge` resource
|
|
169
|
+
- `lib/ruby-asterisk/ari/resources/playback.rb` — `Playback` resource
|
|
170
|
+
- `lib/ruby-asterisk/ari/resources/endpoint.rb` — `Endpoint` resource
|
|
171
|
+
- `lib/ruby-asterisk/ari/resources/collection.rb` — `Collection` (list/get)
|
|
172
|
+
- `lib/ruby-asterisk/ari/resources/base.rb` — `Base` resource (id, data)
|
|
173
|
+
- `spec/ruby-asterisk/ari/client_spec.rb`
|
|
174
|
+
- `spec/ruby-asterisk/ari/resources/`
|