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
data/README.md
CHANGED
|
@@ -1,248 +1,550 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ruby-asterisk
|
|
2
2
|
|
|
3
|
-
[](https://codeclimate.com/github/emilianodellacasa/ruby-asterisk)
|
|
4
|
-
[](https://www.0crat.com/contrib/CE8UGB6NP)
|
|
5
|
-
[](https://www.0crat.com/p/CE8UGB6NP)
|
|
6
3
|
[](https://codeclimate.com/github/emilianodellacasa/ruby-asterisk/maintainability)
|
|
7
4
|
|
|
8
|
-
|
|
5
|
+
A Ruby client for [Asterisk PBX](https://www.asterisk.org/) covering three interfaces:
|
|
9
6
|
|
|
10
|
-
|
|
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 |
|
|
11
13
|
|
|
12
|
-
##
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- **Ruby ≥ 3.1**
|
|
17
|
+
- Runtime dependencies: `async ~> 2.0`, `faraday >= 1.0`, `websocket-driver ~> 0.8`
|
|
13
18
|
|
|
14
|
-
|
|
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.
|
|
15
20
|
|
|
16
|
-
|
|
21
|
+
## Installation
|
|
17
22
|
|
|
18
23
|
```ruby
|
|
19
|
-
|
|
24
|
+
gem 'ruby-asterisk'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bundle install
|
|
20
29
|
```
|
|
21
30
|
|
|
22
|
-
|
|
31
|
+
## AMI — Asterisk Manager Interface
|
|
23
32
|
|
|
24
|
-
|
|
33
|
+
### Initialize and connect
|
|
25
34
|
|
|
26
35
|
```ruby
|
|
27
|
-
|
|
28
|
-
```
|
|
36
|
+
require 'ruby-asterisk'
|
|
29
37
|
|
|
30
|
-
|
|
38
|
+
ami = RubyAsterisk::AMI::Client.new(host: '192.168.1.1', port: 5038)
|
|
39
|
+
ami.connect
|
|
40
|
+
```
|
|
31
41
|
|
|
32
|
-
###
|
|
42
|
+
### The Promise model
|
|
33
43
|
|
|
34
|
-
|
|
44
|
+
Every AMI command returns a `RubyAsterisk::AMI::Promise` immediately — it does **not** block. Call `.value(timeout)` to wait for the `Response`:
|
|
35
45
|
|
|
36
46
|
```ruby
|
|
37
|
-
|
|
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
|
|
38
63
|
```
|
|
39
64
|
|
|
40
|
-
|
|
65
|
+
`disconnect` rejects all pending promises with a `RuntimeError`.
|
|
41
66
|
|
|
42
|
-
|
|
67
|
+
### Login / Logoff
|
|
43
68
|
|
|
44
69
|
```ruby
|
|
45
|
-
|
|
70
|
+
ami.login(username: 'mark', secret: 'mysecret').value
|
|
71
|
+
ami.logoff.value
|
|
46
72
|
```
|
|
47
73
|
|
|
48
|
-
|
|
74
|
+
### System commands
|
|
49
75
|
|
|
50
|
-
|
|
76
|
+
```ruby
|
|
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
|
|
86
|
+
```
|
|
51
87
|
|
|
52
|
-
|
|
88
|
+
### Channel commands
|
|
53
89
|
|
|
54
90
|
```ruby
|
|
55
|
-
|
|
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
|
|
56
114
|
```
|
|
57
115
|
|
|
58
|
-
###
|
|
59
|
-
|
|
60
|
-
To get a list of all parked calls on your Asterisk PBX, use the following command
|
|
116
|
+
### Queue commands
|
|
61
117
|
|
|
62
118
|
```ruby
|
|
63
|
-
|
|
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
|
|
64
128
|
```
|
|
65
129
|
|
|
66
|
-
###
|
|
130
|
+
### Conference commands
|
|
67
131
|
|
|
68
|
-
|
|
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
|
+
```
|
|
140
|
+
|
|
141
|
+
### Mailbox commands
|
|
69
142
|
|
|
70
143
|
```ruby
|
|
71
|
-
|
|
144
|
+
ami.mailbox_status(mailbox: '1001', context: 'default').value
|
|
145
|
+
ami.mailbox_count(mailbox: '1001', context: 'default').value
|
|
72
146
|
```
|
|
73
147
|
|
|
148
|
+
### SIP commands
|
|
74
149
|
|
|
75
|
-
|
|
150
|
+
```ruby
|
|
151
|
+
ami.sip_peers.value
|
|
152
|
+
ami.sip_show_peer('alice').value
|
|
153
|
+
ami.sip_show_registry.value
|
|
154
|
+
```
|
|
76
155
|
|
|
77
|
-
|
|
156
|
+
### Extension state
|
|
78
157
|
|
|
79
158
|
```ruby
|
|
80
|
-
|
|
159
|
+
response = ami.extension_state(exten: '1001', context: 'default').value
|
|
160
|
+
puts response.data[:hints]
|
|
161
|
+
# => [{ "Exten" => "1001", "Status" => "1",
|
|
162
|
+
# "DescriptiveStatus" => "In Use", ... }]
|
|
81
163
|
```
|
|
82
164
|
|
|
83
|
-
|
|
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`.
|
|
84
169
|
|
|
85
|
-
|
|
170
|
+
### Monitor commands
|
|
86
171
|
|
|
87
172
|
```ruby
|
|
88
|
-
|
|
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
|
|
89
178
|
```
|
|
90
179
|
|
|
91
|
-
###
|
|
92
|
-
|
|
93
|
-
To get the state of an extension use the following command
|
|
180
|
+
### Disconnect
|
|
94
181
|
|
|
95
182
|
```ruby
|
|
96
|
-
|
|
183
|
+
ami.disconnect
|
|
97
184
|
```
|
|
98
185
|
|
|
99
|
-
###
|
|
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 |
|
|
100
196
|
|
|
101
|
-
|
|
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
|
+
---
|
|
200
|
+
|
|
201
|
+
## ARI — Asterisk REST Interface
|
|
202
|
+
|
|
203
|
+
### Initialize
|
|
102
204
|
|
|
103
205
|
```ruby
|
|
104
|
-
|
|
105
|
-
```
|
|
206
|
+
require 'ruby-asterisk'
|
|
106
207
|
|
|
107
|
-
|
|
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
|
+
)
|
|
213
|
+
```
|
|
108
214
|
|
|
109
|
-
|
|
215
|
+
### Asterisk info
|
|
110
216
|
|
|
111
217
|
```ruby
|
|
112
|
-
|
|
218
|
+
info = client.asterisk_info
|
|
219
|
+
puts info['system']['version']
|
|
113
220
|
```
|
|
114
221
|
|
|
115
|
-
|
|
222
|
+
### Channels
|
|
116
223
|
|
|
117
224
|
```ruby
|
|
118
|
-
|
|
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
|
|
119
237
|
```
|
|
120
238
|
|
|
121
|
-
###
|
|
122
|
-
|
|
123
|
-
To pause or unpause a member in a call queue
|
|
124
|
-
|
|
239
|
+
### Bridges
|
|
240
|
+
|
|
125
241
|
```ruby
|
|
126
|
-
|
|
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
|
|
127
248
|
```
|
|
128
249
|
|
|
129
|
-
###
|
|
250
|
+
### Playbacks
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
playback = client.playbacks.get('pb-1')
|
|
254
|
+
playback.control('pause')
|
|
255
|
+
playback.control('unpause')
|
|
256
|
+
playback.control('restart')
|
|
257
|
+
playback.stop
|
|
258
|
+
```
|
|
130
259
|
|
|
131
|
-
|
|
260
|
+
### Endpoints
|
|
132
261
|
|
|
133
262
|
```ruby
|
|
134
|
-
|
|
263
|
+
endpoints = client.endpoints.list
|
|
264
|
+
endpoints.each do |ep|
|
|
265
|
+
puts "#{ep.technology}/#{ep.resource} — #{ep.state}"
|
|
266
|
+
end
|
|
135
267
|
```
|
|
136
268
|
|
|
137
|
-
###
|
|
269
|
+
### Error handling
|
|
138
270
|
|
|
139
|
-
|
|
271
|
+
All ARI methods raise `RubyAsterisk::Error` on HTTP 4xx/5xx:
|
|
140
272
|
|
|
141
273
|
```ruby
|
|
142
|
-
|
|
274
|
+
begin
|
|
275
|
+
channel = client.channels.get('nonexistent-id')
|
|
276
|
+
rescue RubyAsterisk::Error => e
|
|
277
|
+
puts e.message # => "Channel not found"
|
|
278
|
+
end
|
|
143
279
|
```
|
|
144
280
|
|
|
145
|
-
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## ARI WebSocket — Real-time events
|
|
146
284
|
|
|
147
|
-
|
|
285
|
+
### Initialize and connect
|
|
148
286
|
|
|
149
287
|
```ruby
|
|
150
|
-
|
|
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
|
+
)
|
|
151
299
|
```
|
|
152
300
|
|
|
153
|
-
###
|
|
301
|
+
### Register event handlers
|
|
154
302
|
|
|
155
|
-
|
|
303
|
+
```ruby
|
|
304
|
+
ws.on('StasisStart') do |event|
|
|
305
|
+
puts "Call arrived on channel #{event['channel']['id']}"
|
|
306
|
+
end
|
|
307
|
+
|
|
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
|
|
156
320
|
|
|
157
321
|
```ruby
|
|
158
|
-
|
|
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
|
|
334
|
+
```
|
|
335
|
+
|
|
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.
|
|
337
|
+
|
|
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)
|
|
159
350
|
```
|
|
160
351
|
|
|
161
|
-
###
|
|
162
|
-
|
|
163
|
-
To get a status of a single channel or for all channels
|
|
352
|
+
### Initialize and start
|
|
353
|
+
|
|
164
354
|
```ruby
|
|
165
|
-
|
|
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
|
|
166
364
|
```
|
|
167
365
|
|
|
168
|
-
|
|
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.
|
|
169
371
|
|
|
170
|
-
|
|
372
|
+
### Stop
|
|
171
373
|
|
|
172
374
|
```ruby
|
|
173
|
-
|
|
375
|
+
# In a signal handler or another thread:
|
|
376
|
+
server.stop
|
|
174
377
|
```
|
|
175
378
|
|
|
176
|
-
|
|
379
|
+
Active sessions finish naturally after `stop` is called.
|
|
177
380
|
|
|
178
|
-
|
|
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**
|
|
179
386
|
|
|
180
387
|
```ruby
|
|
181
|
-
|
|
388
|
+
session.env
|
|
389
|
+
# => { "agi_network" => "yes", "agi_channel" => "SIP/alice-001",
|
|
390
|
+
# "agi_callerid" => "0391234567", ... }
|
|
182
391
|
```
|
|
183
392
|
|
|
184
|
-
|
|
393
|
+
**Call control**
|
|
185
394
|
|
|
186
|
-
|
|
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**
|
|
187
404
|
|
|
188
405
|
```ruby
|
|
189
|
-
|
|
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, '#*')
|
|
190
412
|
```
|
|
191
413
|
|
|
192
|
-
|
|
414
|
+
**DTMF and input**
|
|
193
415
|
|
|
194
|
-
|
|
416
|
+
```ruby
|
|
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]
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
**Variables**
|
|
195
425
|
|
|
196
426
|
```ruby
|
|
197
|
-
|
|
427
|
+
session.set_variable('MY_VAR', 'hello')
|
|
428
|
+
response = session.get_variable('MY_VAR')
|
|
429
|
+
puts response[:data] # => "hello"
|
|
198
430
|
```
|
|
199
431
|
|
|
200
|
-
|
|
432
|
+
**Recording**
|
|
201
433
|
|
|
202
|
-
|
|
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
|
+
```
|
|
442
|
+
|
|
443
|
+
**Execute dialplan application**
|
|
203
444
|
|
|
204
445
|
```ruby
|
|
205
|
-
|
|
446
|
+
session.exec('Playback', 'hello-world')
|
|
447
|
+
session.dial('PJSIP/bob', timeout: 30, options: 'r')
|
|
206
448
|
```
|
|
207
449
|
|
|
208
|
-
|
|
450
|
+
**AstDB**
|
|
209
451
|
|
|
210
|
-
|
|
452
|
+
```ruby
|
|
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')
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
**Logging and text**
|
|
211
461
|
|
|
212
462
|
```ruby
|
|
213
|
-
|
|
463
|
+
session.verbose('Starting IVR flow', level: 2)
|
|
464
|
+
session.send_text('Hello from AGI')
|
|
465
|
+
session.send_image('logo.png')
|
|
214
466
|
```
|
|
215
467
|
|
|
216
|
-
###
|
|
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
|
+
```
|
|
217
491
|
|
|
218
|
-
|
|
492
|
+
### Recording example — with error handling
|
|
219
493
|
|
|
220
494
|
```ruby
|
|
221
|
-
|
|
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
|
|
222
513
|
```
|
|
223
514
|
|
|
224
|
-
###
|
|
515
|
+
### Error handling in sessions
|
|
225
516
|
|
|
226
|
-
|
|
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
|
|
227
520
|
|
|
228
|
-
|
|
229
|
-
- success
|
|
230
|
-
- action_id
|
|
231
|
-
- message
|
|
232
|
-
- data
|
|
233
|
-
- raw_response
|
|
521
|
+
All command methods (`answer`, `stream_file`, etc.) are thin wrappers around `execute` and propagate the same exception.
|
|
234
522
|
|
|
235
|
-
|
|
523
|
+
---
|
|
236
524
|
|
|
237
|
-
##
|
|
525
|
+
## LLM skill files
|
|
238
526
|
|
|
239
|
-
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
+
---
|
|
242
537
|
|
|
243
538
|
## Development
|
|
244
539
|
|
|
245
|
-
|
|
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.
|
|
246
547
|
|
|
247
|
-
|
|
548
|
+
Questions or issues? Please use the [issue tracker](https://github.com/emilianodellacasa/ruby-asterisk/issues). Contributions via fork + pull request are welcome.
|
|
248
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).
|
data/Rakefile
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'bundler'
|
|
2
4
|
Bundler::GemHelper.install_tasks
|
|
3
5
|
|
|
4
6
|
require 'rspec'
|
|
5
7
|
require 'rspec/core/rake_task'
|
|
6
|
-
RSpec::Core::RakeTask.new(:spec)
|
|
7
|
-
|
|
8
|
-
require 'quality/rake/task'
|
|
9
|
-
|
|
10
|
-
Quality::Rake::Task.new
|
|
8
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
11
9
|
|
|
12
10
|
task default: :spec
|