pbx 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/examples/pbx.example.yml +10 -0
- data/exe/pbx +6 -0
- data/lib/pbx/ami_bridge.rb +456 -0
- data/lib/pbx/app.rb +376 -0
- data/lib/pbx/call.rb +9 -0
- data/lib/pbx/call_queue.rb +9 -0
- data/lib/pbx/cli.rb +46 -0
- data/lib/pbx/config.rb +45 -0
- data/lib/pbx/messages.rb +205 -0
- data/lib/pbx/peer.rb +5 -0
- data/lib/pbx/queue_member.rb +7 -0
- data/lib/pbx/status.rb +84 -0
- data/lib/pbx/version.rb +5 -0
- data/lib/pbx/views/active_calls.rb +101 -0
- data/lib/pbx/views/disconnected_screen.rb +60 -0
- data/lib/pbx/views/extension_table.rb +84 -0
- data/lib/pbx/views/footer.rb +51 -0
- data/lib/pbx/views/header.rb +63 -0
- data/lib/pbx/views/info_modal.rb +75 -0
- data/lib/pbx/views/queue_table.rb +62 -0
- data/lib/pbx.rb +20 -0
- metadata +205 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: adfaad3711736dc8a6ac70beec039352cca63d10cbf34bbcc44dbd636686f0bb
|
|
4
|
+
data.tar.gz: 43a811f9771902df35eb46fd1a0aec80c29c76bf95eca8c93c7638528d28192b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7ccaf26bc69f97d122fec0706e2140d7203e50349d656c23d0e805398b3d327afe8dbd30e223c772ac48d4f94397c8ba64ca096bcdc231c80ac4a9688a4e62f1
|
|
7
|
+
data.tar.gz: 2b50fab89612246773ce6c5a4f2919eded7039bc99de42d1e8ffa17ef5cf9ba5302b9942f1f2e65c70dc52783afdd89333ade16279d531b5ce05e8ae69a8636c
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Emiliano Della Casa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# pbx
|
|
2
|
+
|
|
3
|
+
A terminal UI dashboard for monitoring an Asterisk PBX via AMI (Asterisk Manager Interface).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Peers tab** (`p`) — live SIP/PJSIP peer status with IP address, port, type, RTT, and last-change time
|
|
8
|
+
- **Calls tab** (`c`) — active channel tracking with state, dialplan app, connected party, and duration
|
|
9
|
+
- **Queues tab** (`q`) — queue summary with strategy, callers waiting, available/total agents, completed, abandoned, and average hold time
|
|
10
|
+
- All three tabs update in real time from the AMI event stream
|
|
11
|
+
- Press `e` or `Esc` or `Ctrl-C` to quit; press `i` for the info modal
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- Ruby >= 3.2
|
|
16
|
+
- An Asterisk PBX with AMI enabled
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
git clone https://github.com/emilianodellacasa/pbx
|
|
22
|
+
cd pbx
|
|
23
|
+
bundle install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bundle exec exe/pbx monitor --host HOST --port PORT --user USER --secret SECRET
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
All flags are optional and fall back to the values in the config file, then to built-in defaults (`127.0.0.1:5038`).
|
|
33
|
+
|
|
34
|
+
### Options
|
|
35
|
+
|
|
36
|
+
| Flag | Default | Description |
|
|
37
|
+
|------|---------|-------------|
|
|
38
|
+
| `--host` | `127.0.0.1` | AMI host |
|
|
39
|
+
| `--port` | `5038` | AMI port |
|
|
40
|
+
| `--user` | — | AMI username |
|
|
41
|
+
| `--secret` | — | AMI password |
|
|
42
|
+
| `--config`, `-c` | — | Path to a YAML config file |
|
|
43
|
+
| `--debug` | — | Write raw AMI traffic to `/tmp/pbx_debug.log` |
|
|
44
|
+
|
|
45
|
+
### YAML configuration
|
|
46
|
+
|
|
47
|
+
Create a file (e.g. `~/.pbx.yml`) and pass it with `-c`:
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
host: pbx.example.com
|
|
51
|
+
port: 5038
|
|
52
|
+
user: monitor
|
|
53
|
+
secret: s3cret
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
CLI flags override YAML values; YAML values override built-in defaults.
|
|
57
|
+
|
|
58
|
+
## Keyboard shortcuts
|
|
59
|
+
|
|
60
|
+
| Key | Action |
|
|
61
|
+
|-----|--------|
|
|
62
|
+
| `p` | Peers tab |
|
|
63
|
+
| `c` | Calls tab |
|
|
64
|
+
| `q` | Queues tab |
|
|
65
|
+
| `↑` / `↓` | Scroll the active table |
|
|
66
|
+
| `i` | Info modal |
|
|
67
|
+
| `e` / `Esc` / `Ctrl-C` | Quit |
|
|
68
|
+
|
|
69
|
+
## AMI user setup
|
|
70
|
+
|
|
71
|
+
The monitor only needs read access — it issues just `Login`, `SIPpeers`,
|
|
72
|
+
`PJSIPShowEndpoints`, `QueueStatus`, `Events` and `Logoff`, none of which
|
|
73
|
+
require a write class. A minimal `/etc/asterisk/manager.conf` entry:
|
|
74
|
+
|
|
75
|
+
```ini
|
|
76
|
+
[monitor]
|
|
77
|
+
secret = s3cret
|
|
78
|
+
read = system,call,agent,reporting,dialplan
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`system` covers peer and endpoint discovery, `agent` and `reporting` the queue
|
|
82
|
+
events, `call` the channel events and `dialplan` the `Newexten` app updates.
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
bundle exec rspec # run the test suite
|
|
88
|
+
bundle exec standardrb # check code style
|
|
89
|
+
bundle exec standardrb --fix # auto-fix style violations
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Releasing
|
|
93
|
+
|
|
94
|
+
Releases are published to RubyGems by `.github/workflows/release.yml`, triggered
|
|
95
|
+
by pushing a `v*` tag. The workflow refuses to publish unless the tag matches
|
|
96
|
+
`Pbx::VERSION`, the suite and linter pass, and the version is not already on
|
|
97
|
+
RubyGems.
|
|
98
|
+
|
|
99
|
+
One-time setup: add a RubyGems API key with the **push** scope to this
|
|
100
|
+
repository as the `RUBYGEMS_AUTH_TOKEN` secret (*Settings → Secrets and
|
|
101
|
+
variables → Actions*). The name matches the secret the `ruby-asterisk` repo
|
|
102
|
+
already uses; GitHub secrets do not cross repositories, so it has to be added
|
|
103
|
+
here too even if that token is reused.
|
|
104
|
+
|
|
105
|
+
To cut a release:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# 1. bump the version
|
|
109
|
+
$EDITOR lib/pbx/version.rb
|
|
110
|
+
bundle install # refresh the version in Gemfile.lock
|
|
111
|
+
git commit -am "Release v0.2.0"
|
|
112
|
+
|
|
113
|
+
# 2. tag and push — pushing the tag is what publishes
|
|
114
|
+
git tag v0.2.0
|
|
115
|
+
git push origin main --tags
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`bundle exec rake release` (from `bundler/gem_tasks`) also works for publishing
|
|
119
|
+
by hand; `allowed_push_host` in the gemspec keeps either path pointed at
|
|
120
|
+
rubygems.org.
|
data/exe/pbx
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby-asterisk"
|
|
4
|
+
require_relative "peer"
|
|
5
|
+
require_relative "call_queue"
|
|
6
|
+
require_relative "queue_member"
|
|
7
|
+
require_relative "messages"
|
|
8
|
+
require_relative "status"
|
|
9
|
+
|
|
10
|
+
module Pbx
|
|
11
|
+
class AmiBridge
|
|
12
|
+
SENTINEL = :__pbx_shutdown__
|
|
13
|
+
|
|
14
|
+
# Seconds to wait for a list action's aggregated reply. Longer than the
|
|
15
|
+
# gem's 5s default: on a busy PBX a full peer or queue listing is slow.
|
|
16
|
+
LIST_TIMEOUT = 10
|
|
17
|
+
|
|
18
|
+
# AMI frames are CRLF-delimited on the wire; tolerate bare LF too.
|
|
19
|
+
FRAME_DELIMITER = /\r?\n\r?\n/
|
|
20
|
+
|
|
21
|
+
# Subclass that routes parsed AMI events into a Ruby Queue,
|
|
22
|
+
# enabling the Bubbletea command loop to consume them.
|
|
23
|
+
class EventClient < RubyAsterisk::AMI::Client
|
|
24
|
+
def initialize(host:, port:, queue:, log: nil)
|
|
25
|
+
super(host: host, port: port)
|
|
26
|
+
@event_queue = queue
|
|
27
|
+
@log = log
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def pjsip_show_endpoints
|
|
31
|
+
execute "PJSIPShowEndpoints", {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Hook invoked by the reactor for every unsolicited AMI event. Events that
|
|
37
|
+
# belong to a list action's reply never arrive here: the gem buffers them
|
|
38
|
+
# by ActionID and aggregates them into that action's Response instead.
|
|
39
|
+
def handle_event(event)
|
|
40
|
+
super
|
|
41
|
+
if @log
|
|
42
|
+
@log.puts "[DISPATCH] event=#{event.headers.inspect}"
|
|
43
|
+
@log.flush
|
|
44
|
+
end
|
|
45
|
+
@event_queue.push({type: :event, event: event})
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def initialize(config, client: nil, debug: false)
|
|
50
|
+
@config = config
|
|
51
|
+
@queue = Queue.new
|
|
52
|
+
@debug_log = debug ? File.open("/tmp/pbx_debug.log", "a") : nil
|
|
53
|
+
@client = client || EventClient.new(
|
|
54
|
+
host: config.host,
|
|
55
|
+
port: config.port,
|
|
56
|
+
queue: @queue,
|
|
57
|
+
log: @debug_log
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def connect_and_login
|
|
62
|
+
log "[CONNECT] Connecting to #{@config.host}:#{@config.port} as #{@config.user}"
|
|
63
|
+
raise "Could not connect to #{@config.host}:#{@config.port}" unless @client.connect
|
|
64
|
+
|
|
65
|
+
response = @client.login(username: @config.user, secret: @config.secret).value(5)
|
|
66
|
+
log "[CONNECT] Login response: success=#{response&.success} message=#{response&.message}"
|
|
67
|
+
raise "AMI login failed: #{response&.message}" unless response&.success
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def discover_peers
|
|
71
|
+
discover_sip_peers + discover_pjsip_endpoints
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def discover_queues
|
|
75
|
+
log "[QUEUES] Triggering queue_status action..."
|
|
76
|
+
# QueueEntry frames are ignored: the caller count is already in the
|
|
77
|
+
# QueueParams Calls header.
|
|
78
|
+
entries = list_entries(@client.queue_status, "QueueParams", "QueueMember")
|
|
79
|
+
|
|
80
|
+
queues = {}
|
|
81
|
+
entries.each do |data|
|
|
82
|
+
case data["Event"]
|
|
83
|
+
when "QueueParams"
|
|
84
|
+
q = queue_from_params(data)
|
|
85
|
+
queues[q.name] = q if q
|
|
86
|
+
when "QueueMember"
|
|
87
|
+
queue_name = data["Queue"].to_s
|
|
88
|
+
next if queue_name.empty? || !queues[queue_name]
|
|
89
|
+
|
|
90
|
+
member = member_from_event(data)
|
|
91
|
+
next unless member
|
|
92
|
+
|
|
93
|
+
existing = queues[queue_name]
|
|
94
|
+
queues[queue_name] = existing.with(members: existing.members.merge(member.interface => member))
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
log "[QUEUES] Returning #{queues.size} queues: #{queues.keys.inspect}"
|
|
99
|
+
queues
|
|
100
|
+
rescue => e
|
|
101
|
+
log "[QUEUES] Error: #{e.class}: #{e.message}\n#{e.backtrace&.first(3)&.join("\n")}"
|
|
102
|
+
warn "Queue discovery failed: #{e.message}"
|
|
103
|
+
{}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def subscribe
|
|
107
|
+
@client.event_mask("all")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def next_event
|
|
111
|
+
loop do
|
|
112
|
+
raw = @queue.pop
|
|
113
|
+
return nil if raw == SENTINEL
|
|
114
|
+
|
|
115
|
+
msg = translate_event(raw[:event])
|
|
116
|
+
return msg if msg
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def shutdown
|
|
121
|
+
@queue.push(SENTINEL)
|
|
122
|
+
begin
|
|
123
|
+
@client.logoff
|
|
124
|
+
rescue
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
begin
|
|
128
|
+
@client.disconnect
|
|
129
|
+
rescue
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
@debug_log&.close
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def log(msg)
|
|
138
|
+
return unless @debug_log
|
|
139
|
+
|
|
140
|
+
@debug_log.puts "[#{Time.now.strftime("%H:%M:%S")}] #{msg}"
|
|
141
|
+
@debug_log.flush
|
|
142
|
+
rescue IOError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def discover_sip_peers
|
|
147
|
+
log "[DISCOVER] Triggering sip_peers action..."
|
|
148
|
+
entries = list_entries(@client.sip_peers, "PeerEntry")
|
|
149
|
+
log "[DISCOVER] collected #{entries.size} peer entries"
|
|
150
|
+
entries.filter_map { |data| peer_from_sip(data) }
|
|
151
|
+
rescue => e
|
|
152
|
+
log "[DISCOVER] Error: #{e.class}: #{e.message}\n#{e.backtrace&.first(3)&.join("\n")}"
|
|
153
|
+
warn "SIP peer discovery failed: #{e.message}"
|
|
154
|
+
[]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def discover_pjsip_endpoints
|
|
158
|
+
log "[PJSIP] Triggering PJSIPShowEndpoints action..."
|
|
159
|
+
entries = list_entries(@client.pjsip_show_endpoints, "EndpointList")
|
|
160
|
+
log "[PJSIP] collected #{entries.size} PJSIP endpoints"
|
|
161
|
+
entries.filter_map { |data| peer_from_pjsip(data) }
|
|
162
|
+
rescue => e
|
|
163
|
+
log "[PJSIP] Error: #{e.class}: #{e.message}"
|
|
164
|
+
[]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# AMI list actions reply with an ack frame, one Event frame per item and a
|
|
168
|
+
# terminating Complete event. ruby-asterisk buffers that frame set by
|
|
169
|
+
# ActionID and resolves the action's promise with the whole thing, so list
|
|
170
|
+
# entries are read back from the Response rather than the event stream.
|
|
171
|
+
#
|
|
172
|
+
# Returns the header hash of every frame whose Event matches +event_names+,
|
|
173
|
+
# in arrival order. An action that fails (e.g. SIPpeers with chan_sip
|
|
174
|
+
# unloaded) resolves with a plain error frame and yields no entries.
|
|
175
|
+
def list_entries(promise, *event_names)
|
|
176
|
+
raw = promise&.value(LIST_TIMEOUT)&.raw_response
|
|
177
|
+
return [] if raw.nil?
|
|
178
|
+
|
|
179
|
+
raw.join.split(FRAME_DELIMITER).filter_map do |frame|
|
|
180
|
+
headers = RubyAsterisk::AMI::Parser.parse_headers(frame)
|
|
181
|
+
headers if event_names.include?(headers["Event"])
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def queue_from_params(data)
|
|
186
|
+
name = data["Queue"].to_s
|
|
187
|
+
return nil if name.empty?
|
|
188
|
+
|
|
189
|
+
CallQueue.new(
|
|
190
|
+
name: name,
|
|
191
|
+
strategy: data["Strategy"].to_s,
|
|
192
|
+
calls_waiting: data["Calls"].to_i,
|
|
193
|
+
completed: data["Completed"].to_i,
|
|
194
|
+
abandoned: data["Abandoned"].to_i,
|
|
195
|
+
holdtime: data["Holdtime"].to_i,
|
|
196
|
+
members: {}
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def member_from_event(data)
|
|
201
|
+
interface = data["Location"].to_s
|
|
202
|
+
interface = data["Interface"].to_s if interface.empty?
|
|
203
|
+
return nil if interface.empty?
|
|
204
|
+
|
|
205
|
+
QueueMember.new(
|
|
206
|
+
queue: data["Queue"].to_s,
|
|
207
|
+
name: member_name_from(data),
|
|
208
|
+
interface: interface,
|
|
209
|
+
status: Status.queue_member_state(data["Status"].to_s),
|
|
210
|
+
paused: data["Paused"].to_s == "1"
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def member_name_from(h)
|
|
215
|
+
name = h["MemberName"].to_s
|
|
216
|
+
name.empty? ? h["Name"].to_s : name
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def peer_from_sip(data)
|
|
220
|
+
return nil unless data["ObjectName"]
|
|
221
|
+
|
|
222
|
+
name = data["ObjectName"]
|
|
223
|
+
raw_status = data["Status"].to_s
|
|
224
|
+
ip_raw = data["IPaddress"].to_s
|
|
225
|
+
ip_address = (ip_raw.empty? || ip_raw == "-none-") ? nil : ip_raw
|
|
226
|
+
ip_port = data["IPport"].to_s.then { |p| (p.empty? || p == "0") ? nil : p.to_i }
|
|
227
|
+
|
|
228
|
+
Peer.new(
|
|
229
|
+
id: name,
|
|
230
|
+
name: name,
|
|
231
|
+
ip_address: ip_address,
|
|
232
|
+
ip_port: ip_port,
|
|
233
|
+
status: Status.from_sip(raw_status),
|
|
234
|
+
type: data["Type"],
|
|
235
|
+
dynamic: data["Dynamic"],
|
|
236
|
+
user_agent: data["SIP-Useragent"],
|
|
237
|
+
rtt_ms: Status.rtt_from_sip(raw_status),
|
|
238
|
+
last_change_at: nil
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def peer_from_pjsip(data)
|
|
243
|
+
name = data["ObjectName"].to_s
|
|
244
|
+
return nil if name.empty?
|
|
245
|
+
|
|
246
|
+
Peer.new(
|
|
247
|
+
id: name,
|
|
248
|
+
name: name,
|
|
249
|
+
ip_address: nil,
|
|
250
|
+
ip_port: nil,
|
|
251
|
+
status: Status.from_pjsip_device_state(data["DeviceState"].to_s),
|
|
252
|
+
type: "PJSIP",
|
|
253
|
+
dynamic: nil,
|
|
254
|
+
user_agent: nil,
|
|
255
|
+
rtt_ms: nil,
|
|
256
|
+
last_change_at: nil
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
DIALPLAN_NOISE_APPS = %w[
|
|
261
|
+
NoOp Verbose Set GotoIf GotoIfTime Goto Return ExecIf Wait
|
|
262
|
+
Answer Progress Ringing ResetCDR NoCDR UserEvent Log Macro MacroExit
|
|
263
|
+
].freeze
|
|
264
|
+
|
|
265
|
+
def translate_event(event)
|
|
266
|
+
log "[EVENT] class=#{event.class}"
|
|
267
|
+
return nil unless event.is_a?(RubyAsterisk::AMI::Event)
|
|
268
|
+
|
|
269
|
+
log "[EVENT] name=#{event.name} headers=#{event.headers.inspect}"
|
|
270
|
+
|
|
271
|
+
h = event.headers
|
|
272
|
+
|
|
273
|
+
case event.name
|
|
274
|
+
when "FullyBooted"
|
|
275
|
+
Messages::SystemInfo.new(
|
|
276
|
+
uptime_secs: h["Uptime"].to_i,
|
|
277
|
+
last_reload_secs: h["LastReload"].to_i,
|
|
278
|
+
received_at: Time.now
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
when "Newchannel"
|
|
282
|
+
return nil unless h["Channel"].to_s.match?(/\A(SIP|PJSIP)\//i)
|
|
283
|
+
|
|
284
|
+
Messages::CallStarted.new(
|
|
285
|
+
uniqueid: h["Uniqueid"],
|
|
286
|
+
channel: h["Channel"],
|
|
287
|
+
caller_id: h["CallerIDNum"].to_s,
|
|
288
|
+
caller_name: h["CallerIDName"].to_s,
|
|
289
|
+
state: h["ChannelStateDesc"].to_s,
|
|
290
|
+
started_at: Time.now
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
when "Hangup"
|
|
294
|
+
return nil unless h["Channel"].to_s.match?(/\A(SIP|PJSIP)\//i)
|
|
295
|
+
|
|
296
|
+
Messages::CallEnded.new(uniqueid: h["Uniqueid"])
|
|
297
|
+
|
|
298
|
+
when "DialBegin"
|
|
299
|
+
connected = h["DestCallerIDNum"].to_s
|
|
300
|
+
connected = nil if connected.empty? || connected == "<unknown>"
|
|
301
|
+
Messages::CallStateChanged.new(
|
|
302
|
+
uniqueid: h["Uniqueid"],
|
|
303
|
+
state: "Dialing",
|
|
304
|
+
connected_to: connected
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
when "ChannelStateChange"
|
|
308
|
+
return nil unless h["Channel"].to_s.match?(/\A(SIP|PJSIP)\//i)
|
|
309
|
+
|
|
310
|
+
connected = h["ConnectedLineNum"].to_s
|
|
311
|
+
connected = nil if connected.empty? || connected == "<unknown>"
|
|
312
|
+
Messages::CallStateChanged.new(
|
|
313
|
+
uniqueid: h["Uniqueid"],
|
|
314
|
+
state: h["ChannelStateDesc"].to_s,
|
|
315
|
+
connected_to: connected
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
when "DialEnd"
|
|
319
|
+
Messages::DialCompleted.new(
|
|
320
|
+
uniqueid: h["Uniqueid"].to_s,
|
|
321
|
+
dial_status: h["DialStatus"].to_s
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
when "Hold", "MusicOnHoldStart"
|
|
325
|
+
uniqueid = h["Uniqueid"].to_s
|
|
326
|
+
return nil if uniqueid.empty?
|
|
327
|
+
Messages::CallHeld.new(uniqueid: uniqueid)
|
|
328
|
+
|
|
329
|
+
when "Unhold", "MusicOnHoldStop"
|
|
330
|
+
uniqueid = h["Uniqueid"].to_s
|
|
331
|
+
return nil if uniqueid.empty?
|
|
332
|
+
Messages::CallUnheld.new(uniqueid: uniqueid)
|
|
333
|
+
|
|
334
|
+
when "Newexten"
|
|
335
|
+
uniqueid = h["Uniqueid"].to_s
|
|
336
|
+
app = h["Application"].to_s
|
|
337
|
+
return nil if uniqueid.empty? || app.empty?
|
|
338
|
+
return nil if DIALPLAN_NOISE_APPS.include?(app)
|
|
339
|
+
Messages::CallDialplanUpdate.new(
|
|
340
|
+
uniqueid: uniqueid,
|
|
341
|
+
context: h["Context"].to_s,
|
|
342
|
+
exten: h["Extension"].to_s,
|
|
343
|
+
application: app
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
when "QueueCallerJoin", "QueueCallerLeave"
|
|
347
|
+
queue = h["Queue"].to_s
|
|
348
|
+
return nil if queue.empty?
|
|
349
|
+
|
|
350
|
+
Messages::QueueCallerCountChanged.new(
|
|
351
|
+
queue: queue,
|
|
352
|
+
count: h["Count"].to_i
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
when "QueueCallerAbandon"
|
|
356
|
+
queue = h["Queue"].to_s
|
|
357
|
+
return nil if queue.empty?
|
|
358
|
+
|
|
359
|
+
Messages::QueueCallerAbandoned.new(queue: queue)
|
|
360
|
+
|
|
361
|
+
when "QueueMemberStatus", "QueueMemberAdded"
|
|
362
|
+
queue = h["Queue"].to_s
|
|
363
|
+
interface = h["Location"].to_s
|
|
364
|
+
interface = h["Interface"].to_s if interface.empty?
|
|
365
|
+
return nil if queue.empty? || interface.empty?
|
|
366
|
+
|
|
367
|
+
Messages::QueueMemberUpdated.new(
|
|
368
|
+
queue: queue,
|
|
369
|
+
interface: interface,
|
|
370
|
+
name: member_name_from(h),
|
|
371
|
+
status: Status.queue_member_state(h["Status"].to_s),
|
|
372
|
+
paused: h["Paused"].to_s == "1"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
when "QueueMemberPause"
|
|
376
|
+
queue = h["Queue"].to_s
|
|
377
|
+
interface = h["Location"].to_s
|
|
378
|
+
interface = h["Interface"].to_s if interface.empty?
|
|
379
|
+
return nil if queue.empty? || interface.empty?
|
|
380
|
+
|
|
381
|
+
Messages::QueueMemberUpdated.new(
|
|
382
|
+
queue: queue,
|
|
383
|
+
interface: interface,
|
|
384
|
+
name: member_name_from(h),
|
|
385
|
+
status: nil,
|
|
386
|
+
paused: h["Paused"].to_s == "1"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
when "AgentComplete"
|
|
390
|
+
queue = h["Queue"].to_s
|
|
391
|
+
return nil if queue.empty?
|
|
392
|
+
|
|
393
|
+
Messages::QueueCallCompleted.new(
|
|
394
|
+
queue: queue,
|
|
395
|
+
holdtime: h["HoldTime"].to_i
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
when "ContactStatus"
|
|
399
|
+
aor = h["AOR"].to_s
|
|
400
|
+
return nil if aor.empty?
|
|
401
|
+
|
|
402
|
+
status_raw = h["ContactStatus"].to_s
|
|
403
|
+
return nil if status_raw.empty?
|
|
404
|
+
|
|
405
|
+
rtt_usec = h["RoundtripUsec"].to_s.then { |t| t.empty? ? nil : (t.to_f / 1000).round }
|
|
406
|
+
|
|
407
|
+
Messages::PeerStatusChanged.new(
|
|
408
|
+
peer_name: aor,
|
|
409
|
+
status: Status.from_sip(status_raw),
|
|
410
|
+
ip_address: nil,
|
|
411
|
+
ip_port: nil,
|
|
412
|
+
rtt_ms: rtt_usec,
|
|
413
|
+
at: Time.now
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
when "QueueMemberRemoved"
|
|
417
|
+
queue = h["Queue"].to_s
|
|
418
|
+
interface = h["Location"].to_s
|
|
419
|
+
interface = h["Interface"].to_s if interface.empty?
|
|
420
|
+
return nil if queue.empty? || interface.empty?
|
|
421
|
+
|
|
422
|
+
Messages::QueueMemberGone.new(queue: queue, interface: interface)
|
|
423
|
+
|
|
424
|
+
when "PeerStatus"
|
|
425
|
+
channel_type = h["ChannelType"].to_s.upcase
|
|
426
|
+
return nil unless %w[SIP PJSIP].include?(channel_type)
|
|
427
|
+
|
|
428
|
+
name = h["Peer"].to_s.sub(/\A(SIP|PJSIP)\//i, "")
|
|
429
|
+
return nil if name.empty?
|
|
430
|
+
|
|
431
|
+
address = h["Address"].to_s
|
|
432
|
+
ip_address, ip_port = parse_address(address)
|
|
433
|
+
rtt_ms = h["Time"].to_s.then { |t| t.empty? ? nil : t.to_i }
|
|
434
|
+
|
|
435
|
+
Messages::PeerStatusChanged.new(
|
|
436
|
+
peer_name: name,
|
|
437
|
+
status: Status.from_sip(h["PeerStatus"].to_s),
|
|
438
|
+
ip_address: ip_address,
|
|
439
|
+
ip_port: ip_port,
|
|
440
|
+
rtt_ms: rtt_ms,
|
|
441
|
+
at: Time.now
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def parse_address(address)
|
|
448
|
+
return [nil, nil] if address.nil? || address.empty?
|
|
449
|
+
|
|
450
|
+
parts = address.split(":")
|
|
451
|
+
ip = parts[0].then { |h| h.empty? ? nil : h }
|
|
452
|
+
port = parts[1].to_s.then { |p| p.empty? ? nil : p.to_i }
|
|
453
|
+
[ip, port]
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|