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 += ` ` + `${esc(text['button-eavesdrop'] || 'Eavesdrop')} `; } - if (permissions.operator_panel_coach) { + if (permissions.active_call_whisper && can_monitor_this_call) { row_html += ` ` + `${esc(text['button-whisper'] || 'Whisper')} `; + } + if (permissions.active_call_barge && can_monitor_this_call) { row_html += ` ` + `${esc(text['button-barge'] || 'Barge')} `; } @@ -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_record @@ -3010,10 +3086,10 @@ function render_ext_block(ext, is_mine) { (permissions.operator_panel_eavesdrop ? `${esc(text['button-eavesdrop'] || 'Eavesdrop')}` : '') + - (permissions.operator_panel_coach + (permissions.active_call_whisper && can_monitor_this_call ? `${esc(text['button-whisper'] || 'Whisper')}` : '') + - (permissions.operator_panel_coach + (permissions.active_call_barge && can_monitor_this_call ? `${esc(text['button-barge'] || 'Barge')}` : '') + (permissions.operator_panel_hangup @@ -3725,17 +3801,37 @@ function on_parked_drop(event) { dragged_parked_uuid = null; const payload = { uuid, destination: park_destination, context: domain_name }; - // Determine whether to use -bleg based on call type. - // For internal ext-to-ext calls: transfer the dragged extension's own - // channel into the parking lot (no -bleg), so the dragged ext is parked - // and the other internal extension is freed. - // For inbound external calls: use -bleg to park the external caller - // and free the local extension. + // Parking leg rules: + // - Inbound call: park A-leg + // - Outbound call: park B-leg + // - Local ext-to-ext: park dragged extension if (source_ext) { const call = calls_map.get(uuid); - const caller_num = (call && (call.caller_caller_id_number || call.caller_id_number || '')).toString().trim(); - const is_internal = caller_num !== '' && extensions_map.has(caller_num); - if (!is_internal) { + const ext = String(source_ext || '').trim(); + const caller_num = ((call && (call.caller_caller_id_number || call.caller_id_number || '')) + '').trim(); + const dest_num = ((call && call.caller_destination_number) + '').trim(); + + const caller_is_extension = caller_num !== '' && extensions_map.has(caller_num); + const dest_is_extension = dest_num !== '' && extensions_map.has(dest_num); + + const call_presence = (((call || {}).channel_presence_id || '').split('@')[0] || '').trim(); + const fs_direction = ((((call || {}).call_direction || (call || {}).variable_call_direction || '') + '')).toLowerCase(); + let direction_raw = ''; + if (dest_num === ext && caller_num !== ext && caller_num) { + direction_raw = 'inbound'; + } else if (caller_num === ext && dest_num !== ext && dest_num) { + direction_raw = 'outbound'; + } else if (call_presence === ext && fs_direction) { + direction_raw = (fs_direction === 'outbound') ? 'inbound' : 'outbound'; + } else { + direction_raw = fs_direction; + } + + const peer_number = call ? resolve_peer_number_for_leg(call, ext, direction_raw) : ''; + const peer_is_extension = peer_number !== '' && peer_number !== ext && extensions_map.has(peer_number); + const is_local_extension_call = (caller_is_extension && dest_is_extension) || peer_is_extension; + + if (!is_local_extension_call) { payload.bleg = true; } }