diff --git a/app/operator_panel/app_config.php b/app/operator_panel/app_config.php
index 1007c9f3c..c10254d4a 100644
--- a/app/operator_panel/app_config.php
+++ b/app/operator_panel/app_config.php
@@ -96,6 +96,10 @@
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
+ $apps[$x]['permissions'][$y]['name'] = "active_call_whisper";
+ $y++;
+ $apps[$x]['permissions'][$y]['name'] = "active_call_barge";
+ $y++;
// Tab visibility permissions
$apps[$x]['permissions'][$y]['name'] = "operator_panel_extensions";
diff --git a/app/operator_panel/index.php b/app/operator_panel/index.php
index 3aaf56e51..d68ab5b4f 100644
--- a/app/operator_panel/index.php
+++ b/app/operator_panel/index.php
@@ -35,6 +35,9 @@
exit;
}
+// Globals from require.php
+ global $config, $database, $settings;
+
// Multi-lingual support
$language = new text;
$text = $language->get();
@@ -54,6 +57,8 @@
'operator_panel_manage' => permission_exists('operator_panel_manage'),
'operator_panel_hangup' => permission_exists('operator_panel_hangup'),
'operator_panel_eavesdrop' => permission_exists('operator_panel_eavesdrop'),
+ 'active_call_whisper' => permission_exists('active_call_whisper'),
+ 'active_call_barge' => permission_exists('active_call_barge'),
'operator_panel_record' => permission_exists('operator_panel_record'),
'operator_panel_originate' => permission_exists('operator_panel_originate'),
'operator_panel_coach' => permission_exists('operator_panel_coach'),
diff --git a/app/operator_panel/resources/classes/operator_panel_service.php b/app/operator_panel/resources/classes/operator_panel_service.php
index 53ec8a399..372c84c24 100644
--- a/app/operator_panel/resources/classes/operator_panel_service.php
+++ b/app/operator_panel/resources/classes/operator_panel_service.php
@@ -178,8 +178,8 @@ class operator_panel_service extends base_websocket_system_service implements we
'transfer_attended_complete' => 'operator_panel_transfer_attended',
'transfer_attended_cancel' => 'operator_panel_transfer_attended',
'eavesdrop' => 'operator_panel_eavesdrop',
- 'whisper' => 'operator_panel_coach',
- 'barge' => 'operator_panel_coach',
+ 'whisper' => 'active_call_whisper',
+ 'barge' => 'active_call_barge',
'record' => 'operator_panel_record',
'recording_state' => 'operator_panel_record',
'registrations_state' => 'operator_panel_view',
@@ -979,6 +979,102 @@ class operator_panel_service extends base_websocket_system_service implements we
$this->send_action_response($message, $success, $status_message, $extra_payload);
}
+ /**
+ * Normalize FreeSWITCH uuid_getvar output to a usable value.
+ *
+ * @param mixed $value
+ *
+ * @return string
+ */
+ private function normalize_uuid_getvar($value): string {
+ $normalized = trim((string)$value);
+ if ($normalized === '' || $normalized === '_undef_') {
+ return '';
+ }
+ if (stripos($normalized, '-ERR') === 0) {
+ return '';
+ }
+ return $normalized;
+ }
+
+ /**
+ * Resolve the peer leg UUID for a given leg UUID.
+ *
+ * @param string $uuid
+ *
+ * @return string
+ */
+ private function get_peer_leg_uuid(string $uuid): string {
+ $refs = ['other_leg_unique_id', 'bridge_uuid', 'signal_bond'];
+ foreach ($refs as $ref) {
+ $peer_uuid = $this->normalize_uuid_getvar(event_socket::api("uuid_getvar $uuid $ref"));
+ if ($peer_uuid !== '' && $peer_uuid !== $uuid) {
+ return $peer_uuid;
+ }
+ }
+ return '';
+ }
+
+ /**
+ * True when an extension appears on the specified leg (presence/CID/destination).
+ *
+ * @param string $uuid
+ * @param string $extension
+ *
+ * @return bool
+ */
+ private function uuid_leg_has_extension(string $uuid, string $extension): bool {
+ if ($uuid === '' || $extension === '') {
+ return false;
+ }
+
+ $presence = $this->normalize_uuid_getvar(event_socket::api("uuid_getvar $uuid channel_presence_id"));
+ if ($presence !== '') {
+ $presence_ext = explode('@', $presence)[0] ?? '';
+ if (trim($presence_ext) === $extension) {
+ return true;
+ }
+ }
+
+ $dest = $this->normalize_uuid_getvar(event_socket::api("uuid_getvar $uuid caller_destination_number"));
+ if ($dest !== '' && $dest === $extension) {
+ return true;
+ }
+
+ $cid = $this->normalize_uuid_getvar(event_socket::api("uuid_getvar $uuid caller_caller_id_number"));
+ if ($cid !== '' && $cid === $extension) {
+ return true;
+ }
+
+ $caller_id_number = $this->normalize_uuid_getvar(event_socket::api("uuid_getvar $uuid caller_id_number"));
+ if ($caller_id_number !== '' && $caller_id_number === $extension) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * True when an extension participates in either leg of the target call.
+ *
+ * @param string $uuid
+ * @param string $extension
+ *
+ * @return bool
+ */
+ private function extension_in_call(string $uuid, string $extension): bool {
+ if ($this->uuid_leg_has_extension($uuid, $extension)) {
+ return true;
+ }
+
+ $peer_uuid = $this->get_peer_leg_uuid($uuid);
+ if ($peer_uuid !== '' && $this->uuid_leg_has_extension($peer_uuid, $extension)) {
+ return true;
+ }
+
+ return false;
+ }
+
/**
* Execute a validated action.
*
@@ -1151,6 +1247,9 @@ class operator_panel_service extends base_websocket_system_service implements we
if (empty($domain_name)) {
return ['success' => false, 'message' => 'domain_name required'];
}
+ if ($this->extension_in_call((string)$uuid, (string)$dest_ext)) {
+ return ['success' => false, 'message' => 'Cannot monitor a call from an extension already on that call'];
+ }
$mode_token = $action === 'whisper' ? 'whisper' : 'barge';
$api_cmd = "bgapi originate {origination_caller_id_name=$mode_token,origination_caller_id_number=$dest_ext}user/$dest_ext@$domain_name &eavesdrop($uuid $mode_token)";
diff --git a/app/operator_panel/resources/javascript/operator_panel.js b/app/operator_panel/resources/javascript/operator_panel.js
index 23a48068a..d8b51878e 100644
--- a/app/operator_panel/resources/javascript/operator_panel.js
+++ b/app/operator_panel/resources/javascript/operator_panel.js
@@ -1387,6 +1387,7 @@ function render_calls_tab() {
const created_ts = ch.caller_channel_created_time || '0';
const elapsed = esc(format_elapsed(created_ts));
const is_recording = call_is_recording(ch, uuid_raw);
+ const can_monitor_this_call = can_monitor_call_with_my_extensions(uuid_raw);
const record_icon = is_recording
? '../operator_panel/resources/images/recording.png'
: '../operator_panel/resources/images/record.png';
@@ -1408,9 +1409,11 @@ function render_calls_tab() {
row_html += ` `
+ `
`;
}
- if (permissions.operator_panel_coach) {
+ if (permissions.active_call_whisper && can_monitor_this_call) {
row_html += ` `
+ ` `;
+ }
+ if (permissions.active_call_barge && can_monitor_this_call) {
row_html += ` `
+ `
`;
}
@@ -2082,6 +2085,7 @@ function on_ext_contextmenu(event, ext_num) {
}
} else if (has_call) {
// active / held
+ const can_monitor_this_call = can_monitor_call_with_my_extensions(uuid);
if (is_mine && permissions.operator_panel_originate) {
items.push({ label: text['label-dial_number'] || 'Dial a Number', icon_class: 'fa-solid fa-phone',
fn: function () { toggle_ext_dialpad(ext_num, null); } });
@@ -2095,9 +2099,11 @@ function on_ext_contextmenu(event, ext_num) {
items.push({ label: text['button-eavesdrop'] || 'Eavesdrop', icon_class: 'fa-solid fa-ear-listen',
fn: function () { action_eavesdrop(uuid); } });
}
- if (permissions.operator_panel_coach) {
+ if (permissions.active_call_whisper && can_monitor_this_call) {
items.push({ label: text['button-whisper'] || 'Whisper', icon_class: 'fa-solid fa-comment-dots',
fn: function () { action_whisper(uuid); } });
+ }
+ if (permissions.active_call_barge && can_monitor_this_call) {
items.push({ label: text['button-barge'] || 'Barge', icon_class: 'fa-solid fa-volume-high',
fn: function () { action_barge(uuid); } });
}
@@ -2161,6 +2167,7 @@ function get_call_row_state(ch) {
function on_call_contextmenu(event, uuid) {
if (!uuid) return;
const items = [];
+ const can_monitor_this_call = can_monitor_call_with_my_extensions(uuid);
const call_info = calls_map.get(uuid) || null;
const source_ext = get_call_source_extension(uuid);
const is_mine = !!(source_ext && Array.isArray(user_own_extensions) && user_own_extensions.includes(source_ext));
@@ -2205,9 +2212,11 @@ function on_call_contextmenu(event, uuid) {
items.push({ label: text['button-eavesdrop'] || 'Eavesdrop', icon_class: 'fa-solid fa-ear-listen',
fn: function () { action_eavesdrop(uuid); } });
}
- if (permissions.operator_panel_coach) {
+ if (permissions.active_call_whisper && can_monitor_this_call) {
items.push({ label: text['button-whisper'] || 'Whisper', icon_class: 'fa-solid fa-comment-dots',
fn: function () { action_whisper(uuid); } });
+ }
+ if (permissions.active_call_barge && can_monitor_this_call) {
items.push({ label: text['button-barge'] || 'Barge', icon_class: 'fa-solid fa-volume-high',
fn: function () { action_barge(uuid); } });
}
@@ -2500,11 +2509,73 @@ function action_barge(uuid) {
action_monitor_mode('barge', uuid, text['button-barge'] || 'Barge started');
}
+function call_leg_has_extension(ch, ext_number) {
+ if (!ch || !ext_number) return false;
+ const ext = String(ext_number).trim();
+ if (!ext) return false;
+
+ const presence = (((ch.channel_presence_id || '').split('@')[0]) || '').trim();
+ if (presence && presence === ext) return true;
+
+ const dest = ((ch.caller_destination_number || '') + '').trim();
+ if (dest && dest === ext) return true;
+
+ const cid = ((ch.caller_caller_id_number || ch.caller_id_number || '') + '').trim();
+ if (cid && cid === ext) return true;
+
+ return false;
+}
+
+function extension_is_already_on_call(uuid, ext_number) {
+ if (!uuid || !ext_number) return false;
+ const related_uuids = get_conversation_call_uuids(uuid);
+ for (const call_uuid of related_uuids) {
+ const ch = calls_map.get(call_uuid);
+ if (call_leg_has_extension(ch, ext_number)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function get_monitor_eligible_extensions(uuid) {
+ if (!uuid || !Array.isArray(user_own_extensions)) return [];
+
+ const unique = [];
+ for (const raw_ext of user_own_extensions) {
+ const ext = String(raw_ext || '').trim();
+ if (!ext || unique.includes(ext)) continue;
+ if (!extension_is_already_on_call(uuid, ext)) unique.push(ext);
+ }
+
+ return unique;
+}
+
+function can_monitor_call_with_my_extensions(uuid) {
+ return get_monitor_eligible_extensions(uuid).length > 0;
+}
+
function action_monitor_mode(mode, uuid, success_message) {
if (!uuid) return;
+ const eligible_extensions = get_monitor_eligible_extensions(uuid);
+ if (!eligible_extensions.length) {
+ show_toast(text['message-monitor_extension_on_call'] || 'Cannot monitor from an extension that is already on this call.', 'warning');
+ return;
+ }
if (Array.isArray(user_own_extensions) && user_own_extensions.length === 1) {
- const ext = user_own_extensions[0];
+ const ext = eligible_extensions[0];
+ send_action(mode, { uuid, destination: ext, destination_extension: ext })
+ .then(() => show_toast(success_message, 'success'))
+ .catch((err) => {
+ console.error(err);
+ show_toast((err && err.message) || (mode + ' failed'), 'danger');
+ });
+ return;
+ }
+
+ if (eligible_extensions.length === 1) {
+ const ext = eligible_extensions[0];
send_action(mode, { uuid, destination: ext, destination_extension: ext })
.then(() => show_toast(success_message, 'success'))
.catch((err) => {
@@ -2516,6 +2587,10 @@ function action_monitor_mode(mode, uuid, success_message) {
const ext = prompt(text['label-your_extension'] || 'Your extension to receive the call:');
if (!ext) return;
+ if (!eligible_extensions.includes(String(ext).trim())) {
+ show_toast(text['message-monitor_extension_on_call'] || 'Cannot monitor from an extension that is already on this call.', 'warning');
+ return;
+ }
send_action(mode, { uuid, destination: ext, destination_extension: ext })
.then(() => show_toast(success_message, 'success'))
@@ -3002,6 +3077,7 @@ function render_ext_block(ext, is_mine) {
: '') +
``;
} else if (has_live_call) {
+ const can_monitor_this_call = can_monitor_call_with_my_extensions(call_uuid || '');
// Active/held call: normal action icons
live_actions_html = `
`
: '') +
- (permissions.operator_panel_coach
+ (permissions.active_call_whisper && can_monitor_this_call
? `