ruby-asterisk 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +5 -5
  2. data/.claude/skills/ruby-asterisk-agi/SKILL.md +317 -0
  3. data/.claude/skills/ruby-asterisk-ami/SKILL.md +187 -0
  4. data/.claude/skills/ruby-asterisk-ari/SKILL.md +174 -0
  5. data/.claude/skills/ruby-asterisk-ari-websocket/SKILL.md +148 -0
  6. data/.github/workflows/ci.yml +79 -0
  7. data/.gitignore +50 -3
  8. data/.rubocop.yml +84 -0
  9. data/CHANGELOG.md +52 -0
  10. data/Gemfile +3 -1
  11. data/LICENCE +21 -0
  12. data/README.md +454 -70
  13. data/Rakefile +3 -1
  14. data/lib/ruby-asterisk/agi/protocol.rb +160 -0
  15. data/lib/ruby-asterisk/agi/server.rb +108 -0
  16. data/lib/ruby-asterisk/agi/session.rb +187 -0
  17. data/lib/ruby-asterisk/ami/client.rb +135 -0
  18. data/lib/ruby-asterisk/ami/commands/channel.rb +55 -0
  19. data/lib/ruby-asterisk/ami/commands/conference.rb +37 -0
  20. data/lib/ruby-asterisk/ami/commands/extension.rb +17 -0
  21. data/lib/ruby-asterisk/ami/commands/mailbox.rb +21 -0
  22. data/lib/ruby-asterisk/ami/commands/monitor.rb +33 -0
  23. data/lib/ruby-asterisk/ami/commands/queue.rb +39 -0
  24. data/lib/ruby-asterisk/ami/commands/sip.rb +25 -0
  25. data/lib/ruby-asterisk/ami/commands/system.rb +50 -0
  26. data/lib/ruby-asterisk/ami/event.rb +22 -0
  27. data/lib/ruby-asterisk/ami/event_list_aggregation.rb +82 -0
  28. data/lib/ruby-asterisk/ami/parser.rb +60 -0
  29. data/lib/ruby-asterisk/ami/promise.rb +108 -0
  30. data/lib/ruby-asterisk/ami/reactor.rb +162 -0
  31. data/lib/ruby-asterisk/ari/client.rb +94 -0
  32. data/lib/ruby-asterisk/ari/resources/base.rb +23 -0
  33. data/lib/ruby-asterisk/ari/resources/bridge.rb +22 -0
  34. data/lib/ruby-asterisk/ari/resources/channel.rb +26 -0
  35. data/lib/ruby-asterisk/ari/resources/collection.rb +27 -0
  36. data/lib/ruby-asterisk/ari/resources/endpoint.rb +22 -0
  37. data/lib/ruby-asterisk/ari/resources/playback.rb +18 -0
  38. data/lib/ruby-asterisk/ari/websocket/connection.rb +143 -0
  39. data/lib/ruby-asterisk/ari/websocket/event_handlers.rb +80 -0
  40. data/lib/ruby-asterisk/ari/websocket/heartbeat.rb +65 -0
  41. data/lib/ruby-asterisk/ari/websocket/reconnect.rb +54 -0
  42. data/lib/ruby-asterisk/ari/websocket/socket_adapter.rb +23 -0
  43. data/lib/ruby-asterisk/ari/websocket.rb +155 -0
  44. data/lib/ruby-asterisk/compat.rb +7 -0
  45. data/lib/ruby-asterisk/error.rb +10 -0
  46. data/lib/ruby-asterisk/parsing_constants.rb +100 -0
  47. data/lib/ruby-asterisk/request.rb +38 -20
  48. data/lib/ruby-asterisk/response.rb +50 -137
  49. data/lib/ruby-asterisk/response_builder.rb +18 -0
  50. data/lib/ruby-asterisk/response_parser.rb +43 -0
  51. data/lib/ruby-asterisk/version.rb +4 -2
  52. data/lib/ruby-asterisk.rb +5 -147
  53. data/ruby-asterisk.gemspec +26 -17
  54. data/spec/ruby-asterisk/agi/protocol_spec.rb +239 -0
  55. data/spec/ruby-asterisk/agi/server_spec.rb +199 -0
  56. data/spec/ruby-asterisk/agi/session_spec.rb +293 -0
  57. data/spec/ruby-asterisk/ami/async_client_spec.rb +256 -0
  58. data/spec/ruby-asterisk/ami/multi_event_spec.rb +57 -0
  59. data/spec/ruby-asterisk/ami/parser_spec.rb +190 -0
  60. data/spec/ruby-asterisk/ami/reactor_spec.rb +290 -0
  61. data/spec/ruby-asterisk/ami_client_spec.rb +68 -0
  62. data/spec/ruby-asterisk/ari/client_spec.rb +168 -0
  63. data/spec/ruby-asterisk/ari/resources/bridge_spec.rb +42 -0
  64. data/spec/ruby-asterisk/ari/resources/channel_spec.rb +57 -0
  65. data/spec/ruby-asterisk/ari/resources/collection_spec.rb +66 -0
  66. data/spec/ruby-asterisk/ari/resources/endpoint_spec.rb +30 -0
  67. data/spec/ruby-asterisk/ari/resources/playback_spec.rb +33 -0
  68. data/spec/ruby-asterisk/ari/websocket_integration_spec.rb +86 -0
  69. data/spec/ruby-asterisk/ari/websocket_spec.rb +374 -0
  70. data/spec/ruby-asterisk/immutability_spec.rb +148 -0
  71. data/spec/ruby-asterisk/request_spec.rb +29 -9
  72. data/spec/ruby-asterisk/response_spec.rb +147 -148
  73. data/spec/spec_helper.rb +14 -0
  74. data/spec/support/mock_ami_server.rb +50 -0
  75. data/spec/support/mock_ari_websocket_server.rb +114 -0
  76. metadata +177 -21
  77. data/CHANGELOG +0 -3
  78. data/spec/ruby-asterisk/ruby_asterisk_spec.rb +0 -150
data/README.md CHANGED
@@ -1,166 +1,550 @@
1
- # RUBY-ASTERISK
1
+ # ruby-asterisk
2
2
 
3
- [![Code Climate](https://codeclimate.com/badge.png)](https://codeclimate.com/github/emilianodellacasa/ruby-asterisk)
3
+ [![Maintainability](https://api.codeclimate.com/v1/badges/2861728929db934eb376/maintainability)](https://codeclimate.com/github/emilianodellacasa/ruby-asterisk/maintainability)
4
4
 
5
- This gem add support to your Ruby or RubyOnRails projects to Asterisk Manager Interface
5
+ A Ruby client for [Asterisk PBX](https://www.asterisk.org/) covering three interfaces:
6
6
 
7
- There was a project with the same name, but it appears to be discontinued so I decided to start a new project
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
- ### Rails3
23
+ ```ruby
24
+ gem 'ruby-asterisk'
25
+ ```
26
+
27
+ ```bash
28
+ bundle install
29
+ ```
30
+
31
+ ## AMI — Asterisk Manager Interface
12
32
 
13
- Add to your Gemfile and run the `bundle` command to install it.
33
+ ### Initialize and connect
14
34
 
15
35
  ```ruby
16
- gem "ruby-asterisk"
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
- ### Rails2
42
+ ### The Promise model
20
43
 
21
- Simply run in your terminal
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
- gem install ruby-asterisk
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
- ## Usage
65
+ `disconnect` rejects all pending promises with a `RuntimeError`.
28
66
 
29
- ### INITIALIZE
67
+ ### Login / Logoff
30
68
 
31
- To create a new AMI session, just call the following command
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
- @ami = RubyAsterisk::AMI.new("192.168.1.1",5038)
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
- ### LOGIN
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
- To log in, provide to the created sessions a valid username and password
116
+ ### Queue commands
40
117
 
41
118
  ```ruby
42
- @ami.login("mark","mysecret")
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
- Like all commands, it will return a Response command that could be parsed accordingly
130
+ ### Conference commands
46
131
 
47
- ### CORE SHOW CHANNELS
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
- To get a list of all channels currently active on your Asterisk installation, use the following command
141
+ ### Mailbox commands
50
142
 
51
143
  ```ruby
52
- @ami.core_show_channels
144
+ ami.mailbox_status(mailbox: '1001', context: 'default').value
145
+ ami.mailbox_count(mailbox: '1001', context: 'default').value
53
146
  ```
54
147
 
55
- ### PARKED CALLS
148
+ ### SIP commands
56
149
 
57
- To get a list of all parked calls on your Asterisk PBX, use the following command
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
- @ami.parked_calls
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
- ### ORIGINATE
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
- To start a new call use the following command
170
+ ### Monitor commands
66
171
 
67
172
  ```ruby
68
- @ami.originate("SIP/9100","OUTGOING","123456","1","var1=12,var2=99") # CALLER, CONTEXT, CALLEE, PRIORITY, VARIABLE
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
- ### COMMAND
201
+ ## ARI — Asterisk REST Interface
73
202
 
74
- To execute a cli command use the following code
203
+ ### Initialize
75
204
 
76
205
  ```ruby
77
- @ami.command("core show channels")
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
- ### MEETME LIST
215
+ ### Asterisk info
216
+
217
+ ```ruby
218
+ info = client.asterisk_info
219
+ puts info['system']['version']
220
+ ```
81
221
 
82
- To get a list of all active conferences use the following command
222
+ ### Channels
83
223
 
84
224
  ```ruby
85
- @ami.meet_me_list
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
- ### EXTENSION STATE
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
- To get the state of an extension use the following command
250
+ ### Playbacks
91
251
 
92
252
  ```ruby
93
- @ami.extension_state(@exten,@context)
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
- To get list of skinny devices
260
+ ### Endpoints
98
261
 
99
262
  ```ruby
100
- @ami.skinny_devices
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
- To get list of skinny lines
269
+ ### Error handling
270
+
271
+ All ARI methods raise `RubyAsterisk::Error` on HTTP 4xx/5xx:
104
272
 
105
273
  ```ruby
106
- @ami.skinny_lines
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
- ### QUEUE PAUSE
110
-
111
- To pause or unpause a member in a call queue
112
-
281
+ ---
282
+
283
+ ## ARI WebSocket Real-time events
284
+
285
+ ### Initialize and connect
286
+
113
287
  ```ruby
114
- @ami.queue_pause("SIP/100", "true", "myqueue", "reason")
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
- ### PING
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
- To ping asterisk AMI
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
- @ami.ping
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
- ### EVENT MASK
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
- To enable or disable sending events to this manager connection
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
- @ami.event_mask("on")
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
- ### SIPpeers
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
- To get a list of sip peers (equivalent to "sip show peers" call on the asterisk server). This can be used to get a buddy list.
443
+ **Execute dialplan application**
136
444
 
137
445
  ```ruby
138
- @ami.sip_peers
446
+ session.exec('Playback', 'hello-world')
447
+ session.dial('PJSIP/bob', timeout: 30, options: 'r')
139
448
  ```
140
449
 
141
- ### STATUS
142
-
143
- To get a status of a single channel or for all channels
450
+ **AstDB**
451
+
144
452
  ```ruby
145
- @ami.status
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
- ### THE RESPONSE OBJECT
460
+ **Logging and text**
149
461
 
150
- The response object contains all information about all data received from Asterisk. Here follows a list of all object's properties:
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
- - type
153
- - success
154
- - action_id
155
- - message
156
- - data
157
- - raw_response
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
- The data property contains all additional information obtained from Asterisk, like for example the list of active channels after a "core show channels" command.
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
- Questions or problems? Please post them on the [issue tracker](https://github.com/emilianodellacasa/ruby-asterisk/issues). You can contribute changes by forking the project and submitting a pull request. You can ensure the tests passing by running `bundle` and `rake`.
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
- This gem is created by Emiliano Della Casa and is under the MIT License and it is distributed by courtesy of [Engim srl](http://www.engim.eu/en).
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).
data/Rakefile CHANGED
@@ -1,8 +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)
8
+ RSpec::Core::RakeTask.new(:spec)
7
9
 
8
10
  task default: :spec