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
data/README.md
CHANGED
|
@@ -1,166 +1,550 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ruby-asterisk
|
|
2
2
|
|
|
3
|
-
[](https://codeclimate.com/github/emilianodellacasa/ruby-asterisk/maintainability)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A Ruby client for [Asterisk PBX](https://www.asterisk.org/) covering three interfaces:
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
| Interface | Use case | Entry point | Concurrency model |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| **AMI** | TCP command/event channel | `RubyAsterisk::AMI::Client` | Threads + Promise (non-blocking) |
|
|
10
|
+
| **ARI HTTP** | REST control API | `RubyAsterisk::ARI::Client` | Synchronous (Faraday) |
|
|
11
|
+
| **ARI WebSocket** | Real-time event stream | `RubyAsterisk::ARI::WebSocket` | websocket-driver + Threads |
|
|
12
|
+
| **AGI / FastAGI** | In-process dialplan logic | `RubyAsterisk::AGI::Server` | Async + Fibers |
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- **Ruby ≥ 3.1**
|
|
17
|
+
- Runtime dependencies: `async ~> 2.0`, `faraday >= 1.0`, `websocket-driver ~> 0.8`
|
|
18
|
+
|
|
19
|
+
> **Ruby 3.1 note**: the `IO#timeout` shim required by the Async Fiber scheduler is applied automatically when the AGI module is loaded — no action needed.
|
|
8
20
|
|
|
9
21
|
## Installation
|
|
10
22
|
|
|
11
|
-
|
|
23
|
+
```ruby
|
|
24
|
+
gem 'ruby-asterisk'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bundle install
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## AMI — Asterisk Manager Interface
|
|
12
32
|
|
|
13
|
-
|
|
33
|
+
### Initialize and connect
|
|
14
34
|
|
|
15
35
|
```ruby
|
|
16
|
-
|
|
36
|
+
require 'ruby-asterisk'
|
|
37
|
+
|
|
38
|
+
ami = RubyAsterisk::AMI::Client.new(host: '192.168.1.1', port: 5038)
|
|
39
|
+
ami.connect
|
|
17
40
|
```
|
|
18
41
|
|
|
19
|
-
###
|
|
42
|
+
### The Promise model
|
|
20
43
|
|
|
21
|
-
|
|
44
|
+
Every AMI command returns a `RubyAsterisk::AMI::Promise` immediately — it does **not** block. Call `.value(timeout)` to wait for the `Response`:
|
|
22
45
|
|
|
23
46
|
```ruby
|
|
24
|
-
|
|
47
|
+
promise = ami.ping # returns immediately
|
|
48
|
+
response = promise.value # blocks up to 5 s (default)
|
|
49
|
+
response = promise.value(10) # custom timeout in seconds
|
|
50
|
+
|
|
51
|
+
puts response.success # => true
|
|
52
|
+
puts response.message # => "Pong"
|
|
53
|
+
puts response.action_id # => "lk3m0p..."
|
|
54
|
+
puts response.data # => structured Hash or Array depending on command
|
|
55
|
+
puts response.raw_response # => raw AMI text
|
|
56
|
+
|
|
57
|
+
# On timeout:
|
|
58
|
+
begin
|
|
59
|
+
response = promise.value(2)
|
|
60
|
+
rescue Timeout::Error => e
|
|
61
|
+
puts e.message
|
|
62
|
+
end
|
|
25
63
|
```
|
|
26
64
|
|
|
27
|
-
|
|
65
|
+
`disconnect` rejects all pending promises with a `RuntimeError`.
|
|
28
66
|
|
|
29
|
-
###
|
|
67
|
+
### Login / Logoff
|
|
30
68
|
|
|
31
|
-
|
|
69
|
+
```ruby
|
|
70
|
+
ami.login(username: 'mark', secret: 'mysecret').value
|
|
71
|
+
ami.logoff.value
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### System commands
|
|
32
75
|
|
|
33
76
|
```ruby
|
|
34
|
-
|
|
77
|
+
ami.ping.value
|
|
78
|
+
ami.command('core show channels').value
|
|
79
|
+
ami.wait_event(timeout: -1).value # -1 = wait forever: .value blocks until an event arrives
|
|
80
|
+
ami.wait_event(timeout: 30).value # bounded wait; .value gives up after 30 s
|
|
81
|
+
ami.event_mask('on').value # enable event delivery on this connection
|
|
82
|
+
ami.parked_calls.value
|
|
83
|
+
ami.device_state_list.value
|
|
84
|
+
ami.skinny_devices.value
|
|
85
|
+
ami.skinny_lines.value
|
|
35
86
|
```
|
|
36
87
|
|
|
37
|
-
###
|
|
88
|
+
### Channel commands
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
ami.core_show_channels.value
|
|
92
|
+
ami.status.value # all channels
|
|
93
|
+
ami.status(channel: 'SIP/alice-001').value
|
|
94
|
+
|
|
95
|
+
# Originate: channel, context, callee, priority
|
|
96
|
+
ami.originate('SIP/9100', 'OUTGOING', '123456', '1',
|
|
97
|
+
caller_id: 'Alice',
|
|
98
|
+
variable: 'var1=12,var2=99',
|
|
99
|
+
timeout: 30_000,
|
|
100
|
+
async: true).value
|
|
101
|
+
|
|
102
|
+
# Originate to application
|
|
103
|
+
ami.originate_app(channel: 'SIP/9100',
|
|
104
|
+
application: 'Playback',
|
|
105
|
+
data: 'hello-world',
|
|
106
|
+
async: true).value
|
|
107
|
+
|
|
108
|
+
ami.redirect('SIP/alice-001', 'default', '100', '1').value
|
|
109
|
+
ami.hangup('SIP/alice-001').value
|
|
110
|
+
ami.atxfer(channel: 'SIP/alice-001',
|
|
111
|
+
exten: '200',
|
|
112
|
+
context: 'default',
|
|
113
|
+
priority: '1').value
|
|
114
|
+
```
|
|
38
115
|
|
|
39
|
-
|
|
116
|
+
### Queue commands
|
|
40
117
|
|
|
41
118
|
```ruby
|
|
42
|
-
|
|
119
|
+
ami.queues.value
|
|
120
|
+
ami.queue_status.value
|
|
121
|
+
ami.queue_summary('support').value
|
|
122
|
+
ami.queue_add('support', 'SIP/agent1', penalty: 1, member_name: 'Alice').value
|
|
123
|
+
ami.queue_remove(queue: 'support', interface: 'SIP/agent1').value
|
|
124
|
+
ami.queue_pause(interface: 'SIP/agent1',
|
|
125
|
+
paused: true,
|
|
126
|
+
queue: 'support',
|
|
127
|
+
reason: 'lunch').value
|
|
43
128
|
```
|
|
44
129
|
|
|
45
|
-
|
|
130
|
+
### Conference commands
|
|
46
131
|
|
|
47
|
-
|
|
132
|
+
```ruby
|
|
133
|
+
ami.meet_me_list.value
|
|
134
|
+
ami.confbridges.value # list all ConfBridge rooms
|
|
135
|
+
ami.confbridge('my-room').value # list participants in a room
|
|
136
|
+
ami.confbridge_mute(conference: 'my-room', channel: 'SIP/alice-001').value
|
|
137
|
+
ami.confbridge_unmute(conference: 'my-room', channel: 'SIP/alice-001').value
|
|
138
|
+
ami.confbridge_kick(conference: 'my-room', channel: 'SIP/alice-001').value
|
|
139
|
+
```
|
|
48
140
|
|
|
49
|
-
|
|
141
|
+
### Mailbox commands
|
|
50
142
|
|
|
51
143
|
```ruby
|
|
52
|
-
|
|
144
|
+
ami.mailbox_status(mailbox: '1001', context: 'default').value
|
|
145
|
+
ami.mailbox_count(mailbox: '1001', context: 'default').value
|
|
53
146
|
```
|
|
54
147
|
|
|
55
|
-
###
|
|
148
|
+
### SIP commands
|
|
56
149
|
|
|
57
|
-
|
|
150
|
+
```ruby
|
|
151
|
+
ami.sip_peers.value
|
|
152
|
+
ami.sip_show_peer('alice').value
|
|
153
|
+
ami.sip_show_registry.value
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Extension state
|
|
58
157
|
|
|
59
158
|
```ruby
|
|
60
|
-
|
|
159
|
+
response = ami.extension_state(exten: '1001', context: 'default').value
|
|
160
|
+
puts response.data[:hints]
|
|
161
|
+
# => [{ "Exten" => "1001", "Status" => "1",
|
|
162
|
+
# "DescriptiveStatus" => "In Use", ... }]
|
|
61
163
|
```
|
|
62
164
|
|
|
63
|
-
|
|
165
|
+
Status values from `RubyAsterisk::DESCRIPTIVE_STATUS`:
|
|
166
|
+
`-1` Extension not found · `0` Idle · `1` In Use · `2` Busy · `3` Unavailable · `4` Ringing · `5` On Hold
|
|
167
|
+
|
|
168
|
+
`DescriptiveStatus` is only added for statuses in that map; any other value yields `nil`.
|
|
64
169
|
|
|
65
|
-
|
|
170
|
+
### Monitor commands
|
|
66
171
|
|
|
67
172
|
```ruby
|
|
68
|
-
|
|
173
|
+
ami.monitor('SIP/alice-001', mix: true, file: '/tmp/call', format: 'wav').value
|
|
174
|
+
ami.pause_monitor('SIP/alice-001').value
|
|
175
|
+
ami.unpause_monitor('SIP/alice-001').value
|
|
176
|
+
ami.change_monitor(channel: 'SIP/alice-001', file: '/tmp/call2').value
|
|
177
|
+
ami.stop_monitor('SIP/alice-001').value
|
|
69
178
|
```
|
|
70
179
|
|
|
180
|
+
### Disconnect
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
ami.disconnect
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### The Response object
|
|
187
|
+
|
|
188
|
+
| Attribute | Type | Description |
|
|
189
|
+
|---|---|---|
|
|
190
|
+
| `success` | Boolean | `true` when raw response contains `Response: Success` |
|
|
191
|
+
| `action_id` | String | ActionID echoed by Asterisk |
|
|
192
|
+
| `message` | String | `Message:` field from the response |
|
|
193
|
+
| `data` | Hash / Array | Structured data parsed by `ResponseParser` (see `PARSE_DATA`) |
|
|
194
|
+
| `raw_response` | String/Array | The unprocessed AMI text |
|
|
195
|
+
| `type` | String | The AMI action name |
|
|
196
|
+
|
|
197
|
+
Commands with structured `data` parsing (`PARSE_DATA`): `CoreShowChannels`, `ParkedCalls`, `Originate`, `MeetMeList`, `ConfbridgeListRooms`, `ConfbridgeList`, `Status`, `ExtensionState`, `DeviceStateList`, `SKINNYdevices`, `SKINNYlines`, `QueuePause`, `Pong`, `Events`, `SIPpeers`, `SIPshowpeer`.
|
|
198
|
+
|
|
199
|
+
---
|
|
71
200
|
|
|
72
|
-
|
|
201
|
+
## ARI — Asterisk REST Interface
|
|
73
202
|
|
|
74
|
-
|
|
203
|
+
### Initialize
|
|
75
204
|
|
|
76
205
|
```ruby
|
|
77
|
-
|
|
206
|
+
require 'ruby-asterisk'
|
|
207
|
+
|
|
208
|
+
client = RubyAsterisk::ARI::Client.new(
|
|
209
|
+
'http://192.168.1.1:8088', # ARI base URL
|
|
210
|
+
'my_api_key', # API key (username; password is empty)
|
|
211
|
+
'my_stasis_app' # Stasis application name
|
|
212
|
+
)
|
|
78
213
|
```
|
|
79
214
|
|
|
80
|
-
###
|
|
215
|
+
### Asterisk info
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
info = client.asterisk_info
|
|
219
|
+
puts info['system']['version']
|
|
220
|
+
```
|
|
81
221
|
|
|
82
|
-
|
|
222
|
+
### Channels
|
|
83
223
|
|
|
84
224
|
```ruby
|
|
85
|
-
|
|
225
|
+
channel = client.channels.get('1553112062.1')
|
|
226
|
+
puts channel.id # => "1553112062.1"
|
|
227
|
+
puts channel.data['name'] # => "PJSIP/alice-00000001"
|
|
228
|
+
|
|
229
|
+
channels = client.channels.list
|
|
230
|
+
channels.each { |ch| puts ch.data['name'] }
|
|
231
|
+
|
|
232
|
+
channel.ring
|
|
233
|
+
channel.answer
|
|
234
|
+
channel.play('sound:hello-world')
|
|
235
|
+
channel.play('sound:tt-monkeys', playbackId: 'pb-1')
|
|
236
|
+
channel.hangup
|
|
86
237
|
```
|
|
87
238
|
|
|
88
|
-
###
|
|
239
|
+
### Bridges
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
bridges = client.bridges.list
|
|
243
|
+
bridge = client.bridges.get('my-bridge-id')
|
|
244
|
+
|
|
245
|
+
bridge.add_channel('1553112062.1')
|
|
246
|
+
bridge.remove_channel('1553112062.1')
|
|
247
|
+
bridge.destroy
|
|
248
|
+
```
|
|
89
249
|
|
|
90
|
-
|
|
250
|
+
### Playbacks
|
|
91
251
|
|
|
92
252
|
```ruby
|
|
93
|
-
|
|
253
|
+
playback = client.playbacks.get('pb-1')
|
|
254
|
+
playback.control('pause')
|
|
255
|
+
playback.control('unpause')
|
|
256
|
+
playback.control('restart')
|
|
257
|
+
playback.stop
|
|
94
258
|
```
|
|
95
|
-
### SKINNY DEVICES AND LINES
|
|
96
259
|
|
|
97
|
-
|
|
260
|
+
### Endpoints
|
|
98
261
|
|
|
99
262
|
```ruby
|
|
100
|
-
|
|
263
|
+
endpoints = client.endpoints.list
|
|
264
|
+
endpoints.each do |ep|
|
|
265
|
+
puts "#{ep.technology}/#{ep.resource} — #{ep.state}"
|
|
266
|
+
end
|
|
101
267
|
```
|
|
102
268
|
|
|
103
|
-
|
|
269
|
+
### Error handling
|
|
270
|
+
|
|
271
|
+
All ARI methods raise `RubyAsterisk::Error` on HTTP 4xx/5xx:
|
|
104
272
|
|
|
105
273
|
```ruby
|
|
106
|
-
|
|
274
|
+
begin
|
|
275
|
+
channel = client.channels.get('nonexistent-id')
|
|
276
|
+
rescue RubyAsterisk::Error => e
|
|
277
|
+
puts e.message # => "Channel not found"
|
|
278
|
+
end
|
|
107
279
|
```
|
|
108
280
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## ARI WebSocket — Real-time events
|
|
284
|
+
|
|
285
|
+
### Initialize and connect
|
|
286
|
+
|
|
113
287
|
```ruby
|
|
114
|
-
|
|
288
|
+
require 'ruby-asterisk'
|
|
289
|
+
|
|
290
|
+
ws = RubyAsterisk::ARI::WebSocket.new(
|
|
291
|
+
'http://192.168.1.1:8088',
|
|
292
|
+
'my_api_key',
|
|
293
|
+
'my_stasis_app',
|
|
294
|
+
auto_reconnect: true, # default: true
|
|
295
|
+
reconnect_delay: 5, # seconds between attempts, default: 5
|
|
296
|
+
ping_interval: 30, # keep-alive ping interval, default: 30
|
|
297
|
+
logger: Logger.new($stdout)
|
|
298
|
+
)
|
|
115
299
|
```
|
|
116
300
|
|
|
117
|
-
###
|
|
301
|
+
### Register event handlers
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
ws.on('StasisStart') do |event|
|
|
305
|
+
puts "Call arrived on channel #{event['channel']['id']}"
|
|
306
|
+
end
|
|
118
307
|
|
|
119
|
-
|
|
308
|
+
ws.on('ChannelStateChange') do |event|
|
|
309
|
+
puts "Channel state: #{event['channel']['state']}"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
ws.on('StasisEnd') do |event|
|
|
313
|
+
puts "Call ended: #{event['channel']['id']}"
|
|
314
|
+
end
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Multiple handlers for the same event type are supported.
|
|
318
|
+
|
|
319
|
+
### Connect and disconnect
|
|
120
320
|
|
|
121
321
|
```ruby
|
|
122
|
-
|
|
322
|
+
ws.connect do |sock|
|
|
323
|
+
puts "WebSocket connected"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Check status
|
|
327
|
+
ws.connected? # => true / false
|
|
328
|
+
|
|
329
|
+
# Send a message (returns false if not connected)
|
|
330
|
+
ws.send_message?({ type: 'ping' })
|
|
331
|
+
|
|
332
|
+
# Disconnect (disables auto-reconnect)
|
|
333
|
+
ws.disconnect
|
|
123
334
|
```
|
|
124
335
|
|
|
125
|
-
|
|
336
|
+
> **Threading note**: `connect` starts a background connection thread that owns the socket read loop. Event callbacks execute in that thread — avoid direct UI updates or non-thread-safe operations inside callbacks.
|
|
126
337
|
|
|
127
|
-
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## AGI — FastAGI server
|
|
341
|
+
|
|
342
|
+
### What is FastAGI?
|
|
343
|
+
|
|
344
|
+
FastAGI lets you run the AGI dialplan logic in an external process that Asterisk connects to via TCP. ruby-asterisk provides a server that handles each Asterisk connection in its own Fiber under the `async` gem, so many concurrent calls share a single OS thread.
|
|
345
|
+
|
|
346
|
+
In Asterisk's `extensions.conf`:
|
|
347
|
+
|
|
348
|
+
```
|
|
349
|
+
exten => 100,1,AGI(agi://192.168.1.100:4573)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Initialize and start
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
require 'ruby-asterisk'
|
|
356
|
+
|
|
357
|
+
server = RubyAsterisk::AGI::Server.new('0.0.0.0', 4573, logger: Logger.new($stdout))
|
|
358
|
+
server.handle do |session|
|
|
359
|
+
session.answer
|
|
360
|
+
session.stream_file('hello-world')
|
|
361
|
+
session.hangup
|
|
362
|
+
end
|
|
363
|
+
server.run # blocks until server.stop is called
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
The handler block **must** be registered with `handle` before calling `run`, otherwise `RubyAsterisk::Error` is raised.
|
|
367
|
+
|
|
368
|
+
### Ephemeral port
|
|
369
|
+
|
|
370
|
+
Pass `port: 0` to bind to any free port; the actual bound port is available in `server.port` after `run` starts.
|
|
371
|
+
|
|
372
|
+
### Stop
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
# In a signal handler or another thread:
|
|
376
|
+
server.stop
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Active sessions finish naturally after `stop` is called.
|
|
380
|
+
|
|
381
|
+
### The Session object
|
|
382
|
+
|
|
383
|
+
Inside the handler block, `session` is a `RubyAsterisk::AGI::Session`. All command methods return a Hash: `{ code: Integer, result: String|nil, data: String|nil }`.
|
|
384
|
+
|
|
385
|
+
**Environment**
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
session.env
|
|
389
|
+
# => { "agi_network" => "yes", "agi_channel" => "SIP/alice-001",
|
|
390
|
+
# "agi_callerid" => "0391234567", ... }
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
**Call control**
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
session.answer
|
|
397
|
+
session.hangup
|
|
398
|
+
session.hangup('SIP/alice-001') # explicit channel
|
|
399
|
+
session.channel_status # status of current channel
|
|
400
|
+
session.channel_status('SIP/b') # explicit channel
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
**Audio playback**
|
|
404
|
+
|
|
405
|
+
```ruby
|
|
406
|
+
session.stream_file('hello-world') # play; no escape digits
|
|
407
|
+
session.stream_file('hello-world', '1234') # play; stop on digit 1/2/3/4
|
|
408
|
+
session.say_digits('12345')
|
|
409
|
+
session.say_digits('12345', '#') # stop on #
|
|
410
|
+
session.say_number(42)
|
|
411
|
+
session.say_number(42, '#*')
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
**DTMF and input**
|
|
128
415
|
|
|
129
416
|
```ruby
|
|
130
|
-
|
|
417
|
+
result = session.get_data('enter-pin', timeout: 5000, max_digits: 4)
|
|
418
|
+
# Like every verb, returns the response Hash; the key's ASCII value
|
|
419
|
+
# (or -1 on timeout) is in response[:result].
|
|
420
|
+
response = session.wait_for_digit(5000)
|
|
421
|
+
digit = response[:result]
|
|
131
422
|
```
|
|
132
423
|
|
|
133
|
-
|
|
424
|
+
**Variables**
|
|
425
|
+
|
|
426
|
+
```ruby
|
|
427
|
+
session.set_variable('MY_VAR', 'hello')
|
|
428
|
+
response = session.get_variable('MY_VAR')
|
|
429
|
+
puts response[:data] # => "hello"
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
**Recording**
|
|
433
|
+
|
|
434
|
+
```ruby
|
|
435
|
+
session.record_file('/tmp/greeting',
|
|
436
|
+
format: 'wav',
|
|
437
|
+
escape_digits: '#',
|
|
438
|
+
timeout_ms: 10_000,
|
|
439
|
+
beep: true,
|
|
440
|
+
silence: 3)
|
|
441
|
+
```
|
|
134
442
|
|
|
135
|
-
|
|
443
|
+
**Execute dialplan application**
|
|
136
444
|
|
|
137
445
|
```ruby
|
|
138
|
-
|
|
446
|
+
session.exec('Playback', 'hello-world')
|
|
447
|
+
session.dial('PJSIP/bob', timeout: 30, options: 'r')
|
|
139
448
|
```
|
|
140
449
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
To get a status of a single channel or for all channels
|
|
450
|
+
**AstDB**
|
|
451
|
+
|
|
144
452
|
```ruby
|
|
145
|
-
|
|
453
|
+
session.database_put('myapp', 'last_caller', '0391234567')
|
|
454
|
+
response = session.database_get('myapp', 'last_caller')
|
|
455
|
+
puts response[:data] # => "0391234567"
|
|
456
|
+
session.database_del('myapp', 'last_caller')
|
|
457
|
+
session.database_deltree('myapp')
|
|
146
458
|
```
|
|
147
459
|
|
|
148
|
-
|
|
460
|
+
**Logging and text**
|
|
149
461
|
|
|
150
|
-
|
|
462
|
+
```ruby
|
|
463
|
+
session.verbose('Starting IVR flow', level: 2)
|
|
464
|
+
session.send_text('Hello from AGI')
|
|
465
|
+
session.send_image('logo.png')
|
|
466
|
+
```
|
|
151
467
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
468
|
+
### IVR example — DTMF menu
|
|
469
|
+
|
|
470
|
+
```ruby
|
|
471
|
+
server = RubyAsterisk::AGI::Server.new('0.0.0.0', 4573)
|
|
472
|
+
server.handle do |session|
|
|
473
|
+
session.answer
|
|
474
|
+
|
|
475
|
+
result = session.get_data('ivr-menu', timeout: 5000, max_digits: 1)
|
|
476
|
+
choice = result[:data].to_s.strip
|
|
477
|
+
|
|
478
|
+
case choice
|
|
479
|
+
when '1'
|
|
480
|
+
session.dial('PJSIP/sales', timeout: 30)
|
|
481
|
+
when '2'
|
|
482
|
+
session.dial('PJSIP/support', timeout: 30)
|
|
483
|
+
else
|
|
484
|
+
session.stream_file('invalid-option')
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
session.hangup
|
|
488
|
+
end
|
|
489
|
+
server.run
|
|
490
|
+
```
|
|
158
491
|
|
|
159
|
-
|
|
492
|
+
### Recording example — with error handling
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
server = RubyAsterisk::AGI::Server.new('0.0.0.0', 4573)
|
|
496
|
+
server.handle do |session|
|
|
497
|
+
session.answer
|
|
498
|
+
session.stream_file('beep')
|
|
499
|
+
|
|
500
|
+
begin
|
|
501
|
+
session.record_file('/tmp/message', format: 'wav',
|
|
502
|
+
escape_digits: '#', timeout_ms: 30_000,
|
|
503
|
+
beep: false, silence: 5)
|
|
504
|
+
session.database_put('voicemail', 'last_recording', '/tmp/message')
|
|
505
|
+
rescue RubyAsterisk::Error => e
|
|
506
|
+
# e.message includes the 520 multi-line error body
|
|
507
|
+
session.verbose("Recording failed: #{e.message}", level: 1)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
session.hangup
|
|
511
|
+
end
|
|
512
|
+
server.run
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
### Error handling in sessions
|
|
516
|
+
|
|
517
|
+
`session.execute` (the underlying method) raises `RubyAsterisk::Error`:
|
|
518
|
+
- On EOF: `"Connection closed by Asterisk"`
|
|
519
|
+
- On AGI 5xx responses, including multi-line `520-…/520 End of proper usage.` errors
|
|
520
|
+
|
|
521
|
+
All command methods (`answer`, `stream_file`, etc.) are thin wrappers around `execute` and propagate the same exception.
|
|
522
|
+
|
|
523
|
+
---
|
|
524
|
+
|
|
525
|
+
## LLM skill files
|
|
526
|
+
|
|
527
|
+
Detailed how-to guides for each subsystem are in `.claude/skills/`. Claude Code loads them automatically as project-level skills; other agent runners can read them as markdown prompts.
|
|
528
|
+
|
|
529
|
+
| Skill file | Covers |
|
|
530
|
+
|---|---|
|
|
531
|
+
| `.claude/skills/ruby-asterisk-ami/SKILL.md` | AMI async client, Promise model, all command groups |
|
|
532
|
+
| `.claude/skills/ruby-asterisk-ari/SKILL.md` | ARI HTTP client, resources, error handling |
|
|
533
|
+
| `.claude/skills/ruby-asterisk-ari-websocket/SKILL.md` | ARI WebSocket events, callbacks, reconnect |
|
|
534
|
+
| `.claude/skills/ruby-asterisk-agi/SKILL.md` | FastAGI server, Session verbs, IVR and recording patterns |
|
|
535
|
+
|
|
536
|
+
---
|
|
160
537
|
|
|
161
538
|
## Development
|
|
162
539
|
|
|
163
|
-
|
|
540
|
+
```bash
|
|
541
|
+
bundle install
|
|
542
|
+
bundle exec rake spec # run all tests
|
|
543
|
+
bundle exec rubocop # code quality checks
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
Test infrastructure: `spec/support/mock_ami_server.rb` provides a mock AMI server for unit tests.
|
|
164
547
|
|
|
165
|
-
|
|
548
|
+
Questions or issues? Please use the [issue tracker](https://github.com/emilianodellacasa/ruby-asterisk/issues). Contributions via fork + pull request are welcome.
|
|
166
549
|
|
|
550
|
+
This gem was created by Emiliano Della Casa and is licensed under the MIT License, distributed by courtesy of [Engim srl](http://www.engim.eu/en).
|