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.
data/lib/pbx/app.rb ADDED
@@ -0,0 +1,376 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bubbletea"
4
+ require "bubbles"
5
+ require "lipgloss"
6
+ require_relative "messages"
7
+ require_relative "views/header"
8
+ require_relative "views/extension_table"
9
+ require_relative "views/active_calls"
10
+ require_relative "views/queue_table"
11
+ require_relative "views/footer"
12
+ require_relative "views/disconnected_screen"
13
+ require_relative "views/info_modal"
14
+
15
+ module Pbx
16
+ class App
17
+ include Bubbletea::Model
18
+
19
+ TICK_INTERVAL = 1 # seconds between "time since last change" refresh
20
+
21
+ attr_reader :extensions, :active_calls, :queues, :status, :error, :width, :height, :config,
22
+ :show_info, :system_boot_at, :last_reload_at, :view_mode
23
+
24
+ def spinner_view = @spinner.view
25
+
26
+ def initialize(bridge:, config:)
27
+ @bridge = bridge
28
+ @config = config
29
+ @extensions = {}
30
+ @active_calls = {}
31
+ @queues = {}
32
+ @status = @config.complete? ? :connecting : :disconnected
33
+ @error = nil
34
+ @width = 80
35
+ @height = 24
36
+ @table = nil
37
+ @calls_table = nil
38
+ @queues_table = nil
39
+ @show_info = false
40
+ @view_mode = :peers
41
+ @spinner = Bubbles::Spinner.new(spinner: Bubbles::Spinners::DOT)
42
+ @system_boot_at = nil
43
+ @last_reload_at = nil
44
+ end
45
+
46
+ def init
47
+ if @config.complete?
48
+ @spinner, spinner_cmd = @spinner.init
49
+ [self, Bubbletea.batch(connect_cmd, tick_cmd, spinner_cmd)]
50
+ else
51
+ [self, tick_cmd]
52
+ end
53
+ end
54
+
55
+ def update(message)
56
+ case message
57
+ when Bubbletea::KeyMessage
58
+ if @show_info
59
+ @show_info = false
60
+ return [self, nil]
61
+ end
62
+
63
+ return [self, Bubbletea.quit] if quit_key?(message)
64
+
65
+ if message.runes? && message.char == "i"
66
+ @show_info = true
67
+ return [self, nil]
68
+ end
69
+
70
+ if message.runes? && message.char == "p" && @status == :connected
71
+ @view_mode = :peers
72
+ rebuild_table
73
+ return [self, nil]
74
+ end
75
+
76
+ if message.runes? && message.char == "c" && @status == :connected
77
+ @view_mode = :calls
78
+ rebuild_table
79
+ return [self, nil]
80
+ end
81
+
82
+ if message.runes? && message.char == "q" && @status == :connected
83
+ @view_mode = :queues
84
+ return [self, nil]
85
+ end
86
+
87
+ active_table = case @view_mode
88
+ when :calls then @calls_table
89
+ when :queues then @queues_table
90
+ else @table
91
+ end
92
+ if active_table && @status == :connected
93
+ updated, table_cmd = active_table.update(message)
94
+ case @view_mode
95
+ when :calls then @calls_table = updated
96
+ when :queues then @queues_table = updated
97
+ else @table = updated
98
+ end
99
+ return [self, table_cmd]
100
+ end
101
+
102
+ when Bubbletea::WindowSizeMessage
103
+ @width = message.width
104
+ @height = message.height
105
+ if @status == :connected
106
+ rebuild_table
107
+ rebuild_calls_table
108
+ rebuild_queues_table
109
+ end
110
+
111
+ when Messages::ConnectionEstablished
112
+ @status = :connected
113
+ @error = nil
114
+ message.peers.each { |p| @extensions[p.id] = p }
115
+ @queues = message.queues
116
+ rebuild_table
117
+ rebuild_queues_table
118
+ return [self, wait_for_event_cmd]
119
+
120
+ when Messages::ConnectionLost
121
+ @status = :lost
122
+ @error = message.reason
123
+ return [self, wait_for_event_cmd]
124
+
125
+ when Messages::PeerDiscovered
126
+ @extensions[message.peer.id] = message.peer
127
+ rebuild_table
128
+ return [self, wait_for_event_cmd]
129
+
130
+ when Messages::PeerStatusChanged
131
+ if (peer = @extensions[message.peer_name])
132
+ @extensions[peer.id] = Peer.new(
133
+ id: peer.id,
134
+ name: peer.name,
135
+ ip_address: message.ip_address || peer.ip_address,
136
+ ip_port: message.ip_port || peer.ip_port,
137
+ status: message.status,
138
+ type: peer.type,
139
+ dynamic: peer.dynamic,
140
+ user_agent: peer.user_agent,
141
+ rtt_ms: message.rtt_ms || peer.rtt_ms,
142
+ last_change_at: message.at
143
+ )
144
+ rebuild_table
145
+ end
146
+ return [self, wait_for_event_cmd]
147
+
148
+ when Messages::CallStarted
149
+ @active_calls[message.uniqueid] = Call.new(
150
+ uniqueid: message.uniqueid,
151
+ channel: message.channel,
152
+ caller_id: message.caller_id,
153
+ caller_name: message.caller_name,
154
+ connected_to: nil,
155
+ state: message.state,
156
+ started_at: message.started_at,
157
+ outcome: nil,
158
+ held: false,
159
+ dialplan_app: nil,
160
+ dialplan_exten: nil
161
+ )
162
+ rebuild_table
163
+ rebuild_calls_table
164
+ return [self, wait_for_event_cmd]
165
+
166
+ when Messages::CallEnded
167
+ @active_calls.delete(message.uniqueid)
168
+ rebuild_table
169
+ rebuild_calls_table
170
+ return [self, wait_for_event_cmd]
171
+
172
+ when Messages::CallStateChanged
173
+ if (call = @active_calls[message.uniqueid])
174
+ @active_calls[call.uniqueid] = call.with(
175
+ connected_to: message.connected_to || call.connected_to,
176
+ state: message.state
177
+ )
178
+ rebuild_table
179
+ rebuild_calls_table
180
+ end
181
+ return [self, wait_for_event_cmd]
182
+
183
+ when Messages::DialCompleted
184
+ if (call = @active_calls[message.uniqueid])
185
+ @active_calls[call.uniqueid] = call.with(outcome: message.dial_status)
186
+ rebuild_table
187
+ rebuild_calls_table
188
+ end
189
+ return [self, wait_for_event_cmd]
190
+
191
+ when Messages::CallHeld
192
+ if (call = @active_calls[message.uniqueid])
193
+ @active_calls[call.uniqueid] = call.with(held: true)
194
+ rebuild_table
195
+ rebuild_calls_table
196
+ end
197
+ return [self, wait_for_event_cmd]
198
+
199
+ when Messages::CallUnheld
200
+ if (call = @active_calls[message.uniqueid])
201
+ @active_calls[call.uniqueid] = call.with(held: false)
202
+ rebuild_table
203
+ rebuild_calls_table
204
+ end
205
+ return [self, wait_for_event_cmd]
206
+
207
+ when Messages::CallDialplanUpdate
208
+ if (call = @active_calls[message.uniqueid])
209
+ @active_calls[call.uniqueid] = call.with(
210
+ dialplan_app: message.application,
211
+ dialplan_exten: message.exten
212
+ )
213
+ rebuild_table
214
+ rebuild_calls_table
215
+ end
216
+ return [self, wait_for_event_cmd]
217
+
218
+ when Messages::QueueCallCompleted
219
+ if (q = @queues[message.queue])
220
+ @queues[message.queue] = q.with(
221
+ completed: q.completed + 1,
222
+ last_holdtime: message.holdtime
223
+ )
224
+ rebuild_queues_table
225
+ end
226
+ return [self, wait_for_event_cmd]
227
+
228
+ when Messages::QueueCallerCountChanged
229
+ if (q = @queues[message.queue])
230
+ @queues[message.queue] = q.with(calls_waiting: message.count)
231
+ rebuild_queues_table
232
+ end
233
+ return [self, wait_for_event_cmd]
234
+
235
+ when Messages::QueueCallerAbandoned
236
+ if (q = @queues[message.queue])
237
+ @queues[message.queue] = q.with(abandoned: q.abandoned + 1)
238
+ rebuild_queues_table
239
+ end
240
+ return [self, wait_for_event_cmd]
241
+
242
+ when Messages::QueueMemberUpdated
243
+ q = @queues[message.queue] || CallQueue.new(
244
+ name: message.queue, strategy: "unknown", calls_waiting: 0,
245
+ completed: 0, abandoned: 0, holdtime: 0, members: {}
246
+ )
247
+ existing = q.members[message.interface]
248
+ updated_member = if existing
249
+ existing.with(
250
+ name: message.name.empty? ? existing.name : message.name,
251
+ status: message.status || existing.status,
252
+ paused: message.paused
253
+ )
254
+ else
255
+ QueueMember.new(
256
+ queue: message.queue,
257
+ name: message.name,
258
+ interface: message.interface,
259
+ status: message.status || "unknown",
260
+ paused: message.paused
261
+ )
262
+ end
263
+ @queues[message.queue] = q.with(members: q.members.merge(message.interface => updated_member))
264
+ rebuild_queues_table
265
+ return [self, wait_for_event_cmd]
266
+
267
+ when Messages::QueueMemberGone
268
+ if (q = @queues[message.queue])
269
+ @queues[message.queue] = q.with(members: q.members.reject { |k, _| k == message.interface })
270
+ rebuild_queues_table
271
+ end
272
+ return [self, wait_for_event_cmd]
273
+
274
+ when Messages::SystemInfo
275
+ @system_boot_at = message.received_at - message.uptime_secs
276
+ @last_reload_at = message.received_at - message.last_reload_secs
277
+ return [self, wait_for_event_cmd]
278
+
279
+ when Messages::AmiError
280
+ @error = message.error.to_s
281
+ return [self, wait_for_event_cmd]
282
+
283
+ when Messages::Tick
284
+ rebuild_table if @status == :connected && !@extensions.empty?
285
+ return [self, tick_cmd]
286
+
287
+ end
288
+
289
+ @spinner, spinner_cmd = @spinner.update(message)
290
+ [self, spinner_cmd]
291
+ end
292
+
293
+ def view
294
+ return Views::InfoModal.call(self) if @show_info
295
+
296
+ header = Views::Header.call(self)
297
+ body = build_body
298
+ footer = Views::Footer.call(self)
299
+ content = Lipgloss.join_vertical(:left, header, body, footer)
300
+ Lipgloss.place(@width, @height, :left, :top, content)
301
+ end
302
+
303
+ private
304
+
305
+ def build_body
306
+ case @status
307
+ when :disconnected
308
+ Views::DisconnectedScreen.call(self)
309
+ when :connecting
310
+ ""
311
+ else
312
+ if @view_mode == :calls
313
+ Views::ActiveCalls.render(@active_calls, @width, list_height(@active_calls.size), table: @calls_table)
314
+ elsif @view_mode == :queues
315
+ Views::QueueTable.render(@queues, @width, list_height(@queues.size), table: @queues_table)
316
+ else
317
+ @extensions.empty? ? Views::ExtensionTable.render_empty : (@table&.view || Views::ExtensionTable.render_empty)
318
+ end
319
+ end
320
+ end
321
+
322
+ def rebuild_table
323
+ table_height = [@height - 6, 5].max
324
+ @table = Views::ExtensionTable.build(@extensions, table_height)
325
+ end
326
+
327
+ def rebuild_calls_table
328
+ @calls_table = Views::ActiveCalls.build(@active_calls, list_height(@active_calls.size))
329
+ end
330
+
331
+ def rebuild_queues_table
332
+ @queues_table = Views::QueueTable.build(@queues, list_height(@queues.size))
333
+ end
334
+
335
+ # Rows available to a list body, never fewer than one. The upper bound is
336
+ # floored before clamping because a bare clamp(1, @height - 8) raises
337
+ # ArgumentError once the terminal is shorter than nine rows.
338
+ def list_height(count)
339
+ count.clamp(1, [@height - 8, 1].max)
340
+ end
341
+
342
+ def connect_cmd
343
+ -> {
344
+ begin
345
+ @bridge.connect_and_login
346
+ peers = @bridge.discover_peers
347
+ queues = @bridge.discover_queues
348
+ @bridge.subscribe
349
+ Messages::ConnectionEstablished.new(
350
+ remote: "#{@config.host}:#{@config.port}",
351
+ peers: peers,
352
+ queues: queues
353
+ )
354
+ rescue => e
355
+ Messages::ConnectionLost.new(reason: e.message)
356
+ end
357
+ }
358
+ end
359
+
360
+ def wait_for_event_cmd
361
+ -> { @bridge.next_event }
362
+ end
363
+
364
+ def tick_cmd
365
+ Bubbletea.tick(TICK_INTERVAL) { Messages::Tick.new(at: Time.now) }
366
+ end
367
+
368
+ def quit_key?(msg)
369
+ return true if msg.esc?
370
+ return true if msg.ctrl? && msg.key_type == Bubbletea::KeyMessage::KEY_CTRL_C
371
+ return true if msg.runes? && msg.char == "e"
372
+
373
+ false
374
+ end
375
+ end
376
+ end
data/lib/pbx/call.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pbx
4
+ Call = Data.define(
5
+ :uniqueid, :channel, :caller_id, :caller_name,
6
+ :connected_to, :state, :started_at,
7
+ :outcome, :held, :dialplan_app, :dialplan_exten
8
+ )
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pbx
4
+ CallQueue = Data.define(:name, :strategy, :calls_waiting, :completed, :abandoned, :holdtime, :last_holdtime, :members) do
5
+ def initialize(name:, strategy:, calls_waiting:, completed:, abandoned:, holdtime:, members:, last_holdtime: nil)
6
+ super
7
+ end
8
+ end
9
+ end
data/lib/pbx/cli.rb ADDED
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "bubbletea"
5
+ require_relative "version"
6
+ require_relative "config"
7
+ require_relative "ami_bridge"
8
+ require_relative "app"
9
+
10
+ module Pbx
11
+ class CLI < Thor
12
+ desc "monitor", "Open the TUI and monitor Asterisk extension status"
13
+ long_desc <<~DESC
14
+ Connects to an Asterisk PBX via AMI, auto-discovers SIP/PJSIP extensions,
15
+ and displays real-time line status in a terminal UI.
16
+
17
+ Connection parameters can be supplied via CLI flags or a YAML config file
18
+ (use --config). CLI flags always override YAML values.
19
+ DESC
20
+ option :host, type: :string, desc: "AMI hostname or IP (default: 127.0.0.1)"
21
+ option :port, type: :numeric, desc: "AMI port (default: 5038)"
22
+ option :user, type: :string, desc: "AMI username"
23
+ option :secret, type: :string, desc: "AMI secret/password"
24
+ option :config, type: :string, aliases: "-c", desc: "Path to YAML config file"
25
+ option :debug, type: :boolean, default: false, desc: "Write AMI debug log to /tmp/pbx_debug.log"
26
+ def monitor
27
+ cli_opts = options.to_h.transform_keys(&:to_sym)
28
+ debug = cli_opts.delete(:debug) { false }
29
+ cfg = Config.load(cli: cli_opts)
30
+ bridge = AmiBridge.new(cfg, debug: debug)
31
+ Bubbletea.run(App.new(bridge: bridge, config: cfg), alt_screen: true)
32
+ rescue Config::Error => e
33
+ warn "Error: #{e.message}"
34
+ exit 1
35
+ ensure
36
+ bridge&.shutdown
37
+ end
38
+
39
+ desc "version", "Print pbx version"
40
+ def version
41
+ puts "pbx #{Pbx::VERSION}"
42
+ end
43
+
44
+ def self.exit_on_failure? = true
45
+ end
46
+ end
data/lib/pbx/config.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Pbx
6
+ module Config
7
+ class Error < StandardError; end
8
+
9
+ Value = Data.define(:host, :port, :user, :secret, :context, :reconnect_backoff) do
10
+ def complete?
11
+ user.to_s.strip.length.positive? && secret.to_s.strip.length.positive?
12
+ end
13
+ end
14
+
15
+ DEFAULTS = {
16
+ host: "127.0.0.1",
17
+ port: 5038,
18
+ user: nil,
19
+ secret: nil,
20
+ context: "default",
21
+ reconnect_backoff: [1, 2, 5, 10]
22
+ }.freeze
23
+
24
+ def self.load(cli: {})
25
+ from_yaml = cli[:config] ? load_yaml(cli[:config]) : {}
26
+ merged = DEFAULTS.merge(from_yaml).merge(compact(cli).except(:config))
27
+ Value.new(**merged)
28
+ end
29
+
30
+ def self.load_yaml(path)
31
+ raw = YAML.safe_load_file(path, symbolize_names: true)
32
+ raw.slice(:host, :port, :user, :secret, :context, :reconnect_backoff)
33
+ rescue Errno::ENOENT
34
+ raise Error, "Config file not found: #{path}"
35
+ rescue Psych::Exception => e
36
+ raise Error, "Invalid YAML config: #{e.message}"
37
+ end
38
+
39
+ def self.compact(hash)
40
+ hash.reject { |_, v| v.nil? }
41
+ end
42
+
43
+ private_class_method :load_yaml, :compact
44
+ end
45
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bubbletea"
4
+
5
+ module Pbx
6
+ module Messages
7
+ class PeerDiscovered < Bubbletea::Message
8
+ attr_reader :peer
9
+
10
+ def initialize(peer:)
11
+ super()
12
+ @peer = peer
13
+ end
14
+ end
15
+
16
+ class PeerStatusChanged < Bubbletea::Message
17
+ attr_reader :peer_name, :status, :ip_address, :ip_port, :rtt_ms, :at
18
+
19
+ def initialize(peer_name:, status:, at:, ip_address: nil, ip_port: nil, rtt_ms: nil)
20
+ super()
21
+ @peer_name = peer_name
22
+ @status = status
23
+ @ip_address = ip_address
24
+ @ip_port = ip_port
25
+ @rtt_ms = rtt_ms
26
+ @at = at
27
+ end
28
+ end
29
+
30
+ class ConnectionEstablished < Bubbletea::Message
31
+ attr_reader :remote, :peers, :queues
32
+
33
+ def initialize(remote:, peers:, queues: {})
34
+ super()
35
+ @remote = remote
36
+ @peers = peers
37
+ @queues = queues
38
+ end
39
+ end
40
+
41
+ class ConnectionLost < Bubbletea::Message
42
+ attr_reader :reason
43
+
44
+ def initialize(reason:)
45
+ super()
46
+ @reason = reason
47
+ end
48
+ end
49
+
50
+ class AmiError < Bubbletea::Message
51
+ attr_reader :error
52
+
53
+ def initialize(error:)
54
+ super()
55
+ @error = error
56
+ end
57
+ end
58
+
59
+ class Tick < Bubbletea::Message
60
+ attr_reader :at
61
+
62
+ def initialize(at:)
63
+ super()
64
+ @at = at
65
+ end
66
+ end
67
+
68
+ class CallStarted < Bubbletea::Message
69
+ attr_reader :uniqueid, :channel, :caller_id, :caller_name, :state, :started_at
70
+
71
+ def initialize(uniqueid:, channel:, caller_id:, caller_name:, state:, started_at:)
72
+ super()
73
+ @uniqueid = uniqueid
74
+ @channel = channel
75
+ @caller_id = caller_id
76
+ @caller_name = caller_name
77
+ @state = state
78
+ @started_at = started_at
79
+ end
80
+ end
81
+
82
+ class CallEnded < Bubbletea::Message
83
+ attr_reader :uniqueid
84
+
85
+ def initialize(uniqueid:)
86
+ super()
87
+ @uniqueid = uniqueid
88
+ end
89
+ end
90
+
91
+ class CallStateChanged < Bubbletea::Message
92
+ attr_reader :uniqueid, :state, :connected_to
93
+
94
+ def initialize(uniqueid:, state:, connected_to: nil)
95
+ super()
96
+ @uniqueid = uniqueid
97
+ @state = state
98
+ @connected_to = connected_to
99
+ end
100
+ end
101
+
102
+ class DialCompleted < Bubbletea::Message
103
+ attr_reader :uniqueid, :dial_status
104
+
105
+ def initialize(uniqueid:, dial_status:)
106
+ super()
107
+ @uniqueid = uniqueid
108
+ @dial_status = dial_status
109
+ end
110
+ end
111
+
112
+ class CallHeld < Bubbletea::Message
113
+ attr_reader :uniqueid
114
+
115
+ def initialize(uniqueid:)
116
+ super()
117
+ @uniqueid = uniqueid
118
+ end
119
+ end
120
+
121
+ class CallUnheld < Bubbletea::Message
122
+ attr_reader :uniqueid
123
+
124
+ def initialize(uniqueid:)
125
+ super()
126
+ @uniqueid = uniqueid
127
+ end
128
+ end
129
+
130
+ class CallDialplanUpdate < Bubbletea::Message
131
+ attr_reader :uniqueid, :context, :exten, :application
132
+
133
+ def initialize(uniqueid:, context:, exten:, application:)
134
+ super()
135
+ @uniqueid = uniqueid
136
+ @context = context
137
+ @exten = exten
138
+ @application = application
139
+ end
140
+ end
141
+
142
+ class QueueCallCompleted < Bubbletea::Message
143
+ attr_reader :queue, :holdtime
144
+
145
+ def initialize(queue:, holdtime:)
146
+ super()
147
+ @queue = queue
148
+ @holdtime = holdtime
149
+ end
150
+ end
151
+
152
+ class QueueCallerCountChanged < Bubbletea::Message
153
+ attr_reader :queue, :count
154
+
155
+ def initialize(queue:, count:)
156
+ super()
157
+ @queue = queue
158
+ @count = count
159
+ end
160
+ end
161
+
162
+ class QueueCallerAbandoned < Bubbletea::Message
163
+ attr_reader :queue
164
+
165
+ def initialize(queue:)
166
+ super()
167
+ @queue = queue
168
+ end
169
+ end
170
+
171
+ class QueueMemberUpdated < Bubbletea::Message
172
+ attr_reader :queue, :interface, :name, :status, :paused
173
+
174
+ def initialize(queue:, interface:, name:, status:, paused:)
175
+ super()
176
+ @queue = queue
177
+ @interface = interface
178
+ @name = name
179
+ @status = status
180
+ @paused = paused
181
+ end
182
+ end
183
+
184
+ class QueueMemberGone < Bubbletea::Message
185
+ attr_reader :queue, :interface
186
+
187
+ def initialize(queue:, interface:)
188
+ super()
189
+ @queue = queue
190
+ @interface = interface
191
+ end
192
+ end
193
+
194
+ class SystemInfo < Bubbletea::Message
195
+ attr_reader :uptime_secs, :last_reload_secs, :received_at
196
+
197
+ def initialize(uptime_secs:, last_reload_secs:, received_at:)
198
+ super()
199
+ @uptime_secs = uptime_secs
200
+ @last_reload_secs = last_reload_secs
201
+ @received_at = received_at
202
+ end
203
+ end
204
+ end
205
+ end
data/lib/pbx/peer.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pbx
4
+ Peer = Data.define(:id, :name, :ip_address, :ip_port, :status, :type, :dynamic, :user_agent, :rtt_ms, :last_change_at)
5
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pbx
4
+ QueueMember = Data.define(:queue, :name, :interface, :status, :paused) do
5
+ def available? = status == "not_in_use" && !paused
6
+ end
7
+ end