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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 53c6e14b4aa50cd5f5e4742021d1b806c58ee640
4
- data.tar.gz: 21d66e82b5e2755110f83c6fcecd235560cd992b
2
+ SHA256:
3
+ metadata.gz: c95a048e0d2735b23389820940e402ff7a48a3f50e4ec2a6f02d4bdd7b4fc202
4
+ data.tar.gz: cd6aa33ec274995602082c1c78a352c6ce32e979b402c35e62efcf728425fb2d
5
5
  SHA512:
6
- metadata.gz: be06cc29301f21aafa7a9d9d72cdfff8cca8cd1822fa447096e60f3db791f3d3bc6cdae8eb3bf8c506d63c077cb8f433ffb55cc01d3856c5f296e81ce6f47ba4
7
- data.tar.gz: ecca78c7fa306058acc211ff90d6d330b0009f3219d3315fd2123190491e678b12103a6e3d3f510edf388a6782efc41f41a3309b1ae4556a8cadd69e51e62ec6
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/`