Add operator panel valet park (#7823)
* Add parked calls monitoring * Introduce a transfer mode * Fix extension color on page load
This commit is contained in:
@@ -56,19 +56,48 @@
|
||||
$apps[$x]['description']['sv-se'] = "";
|
||||
$apps[$x]['description']['uk-ua'] = "";
|
||||
|
||||
// Permissions are inherited from the basic_operator_panel app (uuid: dd3d173a-5d51-4231-ab22-b18c5b712bb2).
|
||||
// The following permissions are reused:
|
||||
// operator_panel_view - view the panel
|
||||
// operator_panel_manage - supervisor: transfer, agent management
|
||||
// operator_panel_originate - originate calls via drag-and-drop
|
||||
// operator_panel_eavesdrop - listen to calls
|
||||
// operator_panel_hangup - disconnect calls
|
||||
// operator_panel_record - record calls
|
||||
// operator_panel_call_details - view caller/callee details
|
||||
// operator_panel_on_demand - on-demand availability status
|
||||
// Core permissions (migrated from basic_operator_panel uuid: dd3d173a-5d51-4231-ab22-b18c5b712bb2)
|
||||
$y = 0;
|
||||
$apps[$x]['permissions'][$y]['name'] = "operator_panel_view";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "operator_panel_manage";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "operator_panel_originate";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "operator_panel_hangup";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "operator_panel_eavesdrop";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "operator_panel_coach";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "operator_panel_record";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "operator_panel_call_details";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "operator_panel_on_demand";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "operator_panel_transfer_attended";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
|
||||
// Tab visibility permissions
|
||||
$y = 0;
|
||||
$apps[$x]['permissions'][$y]['name'] = "operator_panel_extensions";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
|
||||
@@ -485,6 +485,15 @@ $text['label-no_parked_calls']['zh-cn'] = "没有已停泊通话";
|
||||
$text['label-no_parked_calls']['ja-jp'] = "保留中の通話はありません";
|
||||
$text['label-no_parked_calls']['ko-kr'] = "주차된 통화 없음";
|
||||
|
||||
$text['label-parking_lot']['en-us'] = "Parking Lot";
|
||||
$text['label-parking_lot']['en-gb'] = "Parking Lot";
|
||||
|
||||
$text['label-parked_by']['en-us'] = "Parked By";
|
||||
$text['label-parked_by']['en-gb'] = "Parked By";
|
||||
|
||||
$text['label-original_destination']['en-us'] = "Original Destination";
|
||||
$text['label-original_destination']['en-gb'] = "Original Destination";
|
||||
|
||||
$text['label-recording']['en-us'] = "Recording";
|
||||
$text['label-recording']['en-gb'] = "Recording";
|
||||
$text['label-recording']['ar-eg'] = "تسجيل";
|
||||
@@ -566,6 +575,45 @@ $text['label-eavesdrop']['zh-cn'] = "监听";
|
||||
$text['label-eavesdrop']['ja-jp'] = "聞く";
|
||||
$text['label-eavesdrop']['ko-kr'] = "듣기";
|
||||
|
||||
$text['label-choose_action']['en-us'] = "Choose Action";
|
||||
$text['label-choose_action']['en-gb'] = "Choose Action";
|
||||
$text['label-ringing_action_desc']['en-us'] = "Extension {target} is ringing. What would you like to do from {source}?";
|
||||
$text['label-ringing_action_desc']['en-gb'] = "Extension {target} is ringing. What would you like to do from {source}?";
|
||||
$text['button-intercept']['en-us'] = "Intercept";
|
||||
$text['button-intercept']['en-gb'] = "Intercept";
|
||||
$text['button-call']['en-us'] = "Call";
|
||||
$text['button-call']['en-gb'] = "Call";
|
||||
$text['button-reject']['en-us'] = "Reject";
|
||||
$text['button-reject']['en-gb'] = "Reject";
|
||||
$text['button-hangup_caller']['en-us'] = "Hangup Caller";
|
||||
$text['button-hangup_caller']['en-gb'] = "Hangup Caller";
|
||||
$text['label-confirm_hangup_caller']['en-us'] = "Hang up the caller?";
|
||||
$text['label-confirm_hangup_caller']['en-gb'] = "Hang up the caller?";
|
||||
|
||||
$text['label-transfer_mode']['en-us'] = "Transfer";
|
||||
$text['label-transfer_mode']['en-gb'] = "Transfer";
|
||||
$text['label-transfer_mode_title']['en-us'] = "Default transfer type used when transferring calls";
|
||||
$text['label-transfer_mode_title']['en-gb'] = "Default transfer type used when transferring calls";
|
||||
$text['label-blind_transfer']['en-us'] = "Blind";
|
||||
$text['label-blind_transfer']['en-gb'] = "Blind";
|
||||
$text['label-blind_transfer_title']['en-us'] = "Blind transfer: immediately connect the call to the destination";
|
||||
$text['label-blind_transfer_title']['en-gb'] = "Blind transfer: immediately connect the call to the destination";
|
||||
$text['label-attended_transfer']['en-us'] = "Conference";
|
||||
$text['label-attended_transfer']['en-gb'] = "Conference";
|
||||
$text['label-attended_transfer_title']['en-us'] = "Conference transfer: destination is called while the caller is still connected; also called a warm transfer";
|
||||
$text['label-attended_transfer_title']['en-gb'] = "Conference transfer: destination is called while the caller is still connected; also called a warm transfer";
|
||||
|
||||
$text['label-consulting_with']['en-us'] = "Consulting with";
|
||||
$text['label-consulting_with']['en-gb'] = "Consulting with";
|
||||
$text['message-transfer_completed']['en-us'] = "Transfer completed";
|
||||
$text['message-transfer_completed']['en-gb'] = "Transfer completed";
|
||||
$text['message-transfer_cancelled']['en-us'] = "Transfer cancelled";
|
||||
$text['message-transfer_cancelled']['en-gb'] = "Transfer cancelled";
|
||||
$text['button-complete_transfer']['en-us'] = "Complete Transfer";
|
||||
$text['button-complete_transfer']['en-gb'] = "Complete Transfer";
|
||||
$text['button-cancel_transfer']['en-us'] = "Cancel";
|
||||
$text['button-cancel_transfer']['en-gb'] = "Cancel";
|
||||
|
||||
$text['label-hangup']['en-us'] = "Hangup";
|
||||
$text['label-hangup']['en-gb'] = "Hangup";
|
||||
$text['label-hangup']['ar-eg'] = "إنهاء";
|
||||
@@ -1756,6 +1804,9 @@ $text['message-action_failed']['zh-cn'] = "操作失败";
|
||||
$text['message-action_failed']['ja-jp'] = "操作に失敗しました";
|
||||
$text['message-action_failed']['ko-kr'] = "작업에 실패했습니다";
|
||||
|
||||
$text['label-dial_number']['en-us'] = "Dial a Number";
|
||||
$text['label-dial_number']['en-gb'] = "Dial a Number";
|
||||
|
||||
$text['message-permission_denied']['en-us'] = "Permission denied";
|
||||
$text['message-permission_denied']['en-gb'] = "Permission denied";
|
||||
$text['message-permission_denied']['ar-eg'] = "تم رفض الإذن";
|
||||
|
||||
+401
-27
@@ -43,6 +43,11 @@
|
||||
$token = (new token())->create($_SERVER['PHP_SELF']);
|
||||
subscriber::save_token($token, ['active.operator.panel']);
|
||||
|
||||
// Get the status for the current user
|
||||
$sql = 'select user_status from v_users where user_uuid = :user_uuid';
|
||||
$parameters = ['user_uuid' => $_SESSION['user_uuid'] ?? ''];
|
||||
$user_status = $database->select($sql, $parameters, 'column') ?? '';
|
||||
|
||||
// Gather user permissions for the JS side
|
||||
$perm = [
|
||||
'operator_panel_view' => permission_exists('operator_panel_view'),
|
||||
@@ -51,6 +56,10 @@
|
||||
'operator_panel_eavesdrop' => permission_exists('operator_panel_eavesdrop'),
|
||||
'operator_panel_record' => permission_exists('operator_panel_record'),
|
||||
'operator_panel_originate' => permission_exists('operator_panel_originate'),
|
||||
'operator_panel_coach' => permission_exists('operator_panel_coach'),
|
||||
'operator_panel_call_details' => permission_exists('operator_panel_call_details'),
|
||||
'operator_panel_on_demand' => permission_exists('operator_panel_on_demand'),
|
||||
'operator_panel_transfer_attended' => permission_exists('operator_panel_transfer_attended'),
|
||||
'operator_panel_extensions' => permission_exists('operator_panel_extensions'),
|
||||
'operator_panel_calls' => permission_exists('operator_panel_calls'),
|
||||
'operator_panel_conferences' => permission_exists('operator_panel_conferences'),
|
||||
@@ -108,6 +117,12 @@
|
||||
// Optional polling reconciliation of registration state (can be disabled).
|
||||
$registrations_reconcile_enabled = $settings->get('operator_panel', 'registrations_reconcile_enabled', 'false') === 'true';
|
||||
|
||||
// Default auto-park destination for drag/drop parking.
|
||||
$park_destination = (string)$settings->get('operator_panel', 'park_destination', '*5900');
|
||||
if (!preg_match('/^[0-9*#+]+$/', $park_destination)) {
|
||||
$park_destination = '*5900';
|
||||
}
|
||||
|
||||
// Get the logged-in user's own extension numbers (shown at top of Extensions panel)
|
||||
// and primary eavesdrop destination extension
|
||||
$user_own_extensions = [];
|
||||
@@ -162,7 +177,7 @@
|
||||
const user_uuid = <?= json_encode($_SESSION['user_uuid'] ?? '') ?>;
|
||||
|
||||
// User status options
|
||||
const user_statuses = <?= json_encode($user_statuses) ?>;
|
||||
const user_status = <?= json_encode($user_status) ?>;
|
||||
|
||||
// The logged-in user's own extension numbers — shown first / highlighted in the Extensions panel
|
||||
const user_own_extensions = <?= json_encode($user_own_extensions, JSON_UNESCAPED_SLASHES) ?>;
|
||||
@@ -176,6 +191,9 @@
|
||||
// Optional registrations-state reconciliation polling
|
||||
const registrations_reconcile_enabled = <?= json_encode($registrations_reconcile_enabled) ?>;
|
||||
|
||||
// Default auto-park destination for drag/drop parking
|
||||
const park_destination = <?= json_encode($park_destination) ?>;
|
||||
|
||||
</script>
|
||||
|
||||
<script src="resources/javascript/websocket_client.js?v=<?= $ws_client_hash ?>"></script>
|
||||
@@ -318,6 +336,116 @@
|
||||
}
|
||||
.op-edit-btn:hover { background: #e9ecef; }
|
||||
.op-edit-btn.active { background: #0d6efd; color: #fff; border-color: #0d6efd; }
|
||||
/* Transfer mode toggle */
|
||||
.op-transfer-mode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.op-transfer-mode-label {
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
}
|
||||
.op-transfer-mode-btn {
|
||||
padding: 2px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: #555;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
}
|
||||
.op-transfer-mode-btn:hover { background: #e9ecef; }
|
||||
.op-transfer-mode-btn.active { background: #0d6efd; color: #fff; border-color: #0d6efd; }
|
||||
/* Context menu */
|
||||
.op-ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 9990;
|
||||
background: #fff;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.18);
|
||||
min-width: 165px;
|
||||
padding: 4px 0;
|
||||
display: none;
|
||||
margin: 0;
|
||||
}
|
||||
.op-ctx-header {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
padding: 3px 14px 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
.op-ctx-separator {
|
||||
height: 1px;
|
||||
background: #e0e0e0;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.op-ctx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: #24292f;
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.op-ctx-item:hover { background: #f0f6ff; }
|
||||
.op-ctx-item .op-ctx-icon { font-size: 12px; opacity: .7; flex-shrink: 0; }
|
||||
.op-ctx-danger { color: #c9242d; }
|
||||
.op-ctx-danger:hover { background: #fff1f0; }
|
||||
/* Attended transfer consultation bar */
|
||||
.op-att-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9980;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
background: #198754;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 -2px 8px rgba(0,0,0,.2);
|
||||
}
|
||||
.op-att-icon { font-size: 18px; }
|
||||
.op-att-label { flex: 1; }
|
||||
.op-att-btn {
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.op-att-complete {
|
||||
background: #fff;
|
||||
color: #198754;
|
||||
}
|
||||
.op-att-complete:hover { background: #e9ecef; }
|
||||
.op-att-cancel {
|
||||
background: rgba(255,255,255,.2);
|
||||
color: #fff;
|
||||
}
|
||||
.op-att-cancel:hover { background: rgba(255,255,255,.35); }
|
||||
.op-ext-block {
|
||||
display: flex;
|
||||
width: 235px;
|
||||
@@ -602,6 +730,174 @@ body.op-dragging, body.op-dragging * {
|
||||
padding: 10px 8px 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Top row in Extensions tab: My Extensions + Parked Calls */
|
||||
.op-top-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
#my_extensions_container,
|
||||
#parked_side_container {
|
||||
flex: 1 1 420px;
|
||||
min-width: 320px;
|
||||
}
|
||||
#my_extensions_container:not(:empty),
|
||||
#parked_side_container:not(:empty) {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Parked calls side panel */
|
||||
.op-parked-card {
|
||||
border: 1px solid #d0d8e5;
|
||||
border-radius: 5px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 1px 3px #d0d8e5;
|
||||
overflow: hidden;
|
||||
}
|
||||
.op-parked-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: #e5e9f0;
|
||||
border-bottom: 1px solid #d0d8e5;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #444;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
}
|
||||
.op-parked-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: #6c757d;
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.op-parked-list {
|
||||
padding: 8px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
.op-parked-item {
|
||||
border: 1px solid #d0d8e5;
|
||||
border-radius: 4px;
|
||||
background: #f8fbff;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 7px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.op-parked-item:last-child { margin-bottom: 0; }
|
||||
.op-parked-item:hover { background: #eef5ff; border-color: #80bdff; }
|
||||
.op-parked-main { font-size: 12px; font-weight: 600; color: #2c3e50; }
|
||||
.op-parked-sub { font-size: 11px; color: #555; margin-top: 2px; }
|
||||
.op-parked-drop-over {
|
||||
box-shadow: 0 0 0 3px #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
.op-parked-empty {
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
#my_extensions_container,
|
||||
#parked_side_container {
|
||||
flex-basis: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* HTML5 dialog styles */
|
||||
.op-dialog {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
.op-dialog::backdrop {
|
||||
background: rgba(0,0,0,0.4);
|
||||
}
|
||||
.op-dialog-sm {
|
||||
max-width: 320px;
|
||||
}
|
||||
.op-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.op-dialog-header h5 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.op-dialog-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.op-dialog-close:hover { opacity: 1; }
|
||||
.op-dialog-body {
|
||||
padding: 16px;
|
||||
}
|
||||
.op-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
.op-dialog-input {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.op-dialog-input:focus {
|
||||
outline: 2px solid #0d6efd;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.op-dialog-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.op-dialog-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
}
|
||||
.op-btn-primary { background: #0d6efd; }
|
||||
.op-btn-primary:hover { background: #0b5ed7; }
|
||||
.op-btn-success { background: #198754; }
|
||||
.op-btn-success:hover { background: #157347; }
|
||||
.op-btn-info { background: #0dcaf0; color: #000; }
|
||||
.op-btn-info:hover { background: #31d2f2; }
|
||||
.op-btn-secondary { background: #6c757d; }
|
||||
.op-btn-secondary:hover { background: #5c636a; }
|
||||
</style>
|
||||
|
||||
<!-- Bootstrap tabs: Extensions | Calls | Conferences | Agents -->
|
||||
@@ -624,6 +920,15 @@ body.op-dragging, body.op-dragging * {
|
||||
</button>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php if ($perm['operator_panel_extensions']): ?>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-parked" data-bs-toggle="tab" data-bs-target="#panel-parked"
|
||||
type="button" role="tab" aria-controls="panel-parked" aria-selected="false">
|
||||
<?= htmlspecialchars($text['label-parked_calls'] ?? 'Parked Calls') ?>
|
||||
<span id="parked_count" class="badge ms-1" style="background:#6c757d;color:#fff;">0</span>
|
||||
</button>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php if ($perm['operator_panel_conferences']): ?>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-conferences" data-bs-toggle="tab" data-bs-target="#panel-conferences"
|
||||
@@ -656,8 +961,28 @@ body.op-dragging, body.op-dragging * {
|
||||
</button>
|
||||
<div id="group_filter_buttons" class="op-group-filters"></div>
|
||||
<input type="text" id="extensions_text_filter" class="op-text-filter" placeholder="<?= htmlspecialchars($text['label-filter'] ?? 'Filter...') ?>" oninput="apply_extension_filters()">
|
||||
<?php if ($perm['operator_panel_manage']): ?>
|
||||
<!-- <div class="op-transfer-mode" id="transfer_mode_control"> -->
|
||||
<!-- <span class="op-transfer-mode-label"><?= htmlspecialchars($text['label-transfer_mode'] ?? 'Transfer') ?>:</span> -->
|
||||
<?php if ($perm['operator_panel_transfer_attended']): ?>
|
||||
<!-- <button type="button" class="op-transfer-mode-btn active" id="btn_transfer_mode_toggle" onclick="toggle_transfer_mode()" -->
|
||||
<!-- title="<?= htmlspecialchars($text['label-blind_transfer_title'] ?? 'Blind transfer: immediately connect the call to the destination') ?>"> -->
|
||||
<!-- <?= htmlspecialchars($text['label-blind_transfer'] ?? 'Blind') ?> -->
|
||||
<!-- </button> -->
|
||||
<?php else: ?>
|
||||
<!-- <span class="op-transfer-mode-btn active" style="cursor:default;" title="<?= htmlspecialchars($text['label-blind_transfer_title'] ?? 'Blind transfer: immediately connect the call to the destination') ?>"> -->
|
||||
<!-- <?= htmlspecialchars($text['label-blind_transfer'] ?? 'Blind') ?> -->
|
||||
<!-- </span> -->
|
||||
<?php endif; ?>
|
||||
<!-- </div> -->
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="op-top-row" id="extensions_top_row">
|
||||
<div id="my_extensions_container"></div>
|
||||
<div id="parked_side_container">
|
||||
<p class="text-muted"><?= htmlspecialchars($text['label-connecting'] ?? 'Connecting...') ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="my_extensions_container"></div>
|
||||
<div id="extensions_container">
|
||||
<p class="text-muted"><?= htmlspecialchars($text['label-connecting'] ?? 'Connecting...') ?></p>
|
||||
</div>
|
||||
@@ -677,6 +1002,19 @@ body.op-dragging, body.op-dragging * {
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- PARKED CALLS TAB -->
|
||||
<?php if ($perm['operator_panel_extensions']): ?>
|
||||
<div class="tab-pane fade" id="panel-parked" role="tabpanel" aria-labelledby="tab-parked">
|
||||
<div id="parked_filter_bar" class="op-filter-bar" style="display:none;">
|
||||
<div id="group_filter_buttons_parked" class="op-group-filters"></div>
|
||||
<input type="text" id="parked_text_filter" class="op-text-filter" placeholder="<?= htmlspecialchars($text['label-filter'] ?? 'Filter...') ?>" oninput="apply_parked_filters()">
|
||||
</div>
|
||||
<div id="parked_container">
|
||||
<p class="text-muted"><?= htmlspecialchars($text['label-connecting'] ?? 'Connecting...') ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- CONFERENCES TAB -->
|
||||
<?php if ($perm['operator_panel_conferences']): ?>
|
||||
<div class="tab-pane fade" id="panel-conferences" role="tabpanel" aria-labelledby="tab-conferences">
|
||||
@@ -705,33 +1043,69 @@ body.op-dragging, body.op-dragging * {
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Transfer modal -->
|
||||
<div class="modal fade" id="transfer_modal" tabindex="-1" aria-labelledby="transfer_modal_label" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content" style="background:var(--bs-body-bg);">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="transfer_modal_label"><?= htmlspecialchars($text['label-transfer'] ?? 'Transfer Call') ?></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label for="transfer_destination" class="form-label" style="font-weight:600;">
|
||||
<?= htmlspecialchars($text['label-destination'] ?? 'Destination') ?>
|
||||
</label>
|
||||
<input type="text" id="transfer_destination" class="form-control" placeholder="1001"
|
||||
autocomplete="off" autofocus>
|
||||
<input type="hidden" id="transfer_uuid">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<?= htmlspecialchars($text['button-cancel'] ?? 'Cancel') ?>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="confirm_transfer()">
|
||||
<?= htmlspecialchars($text['button-transfer'] ?? 'Transfer') ?>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Right-click context menu -->
|
||||
<div id="op_context_menu" class="op-ctx-menu" role="menu" aria-label="<?= htmlspecialchars($text['label-actions'] ?? 'Actions') ?>"></div>
|
||||
|
||||
<!-- Attended transfer consultation bar -->
|
||||
<div id="attended_transfer_bar" class="op-att-bar" style="display:none;">
|
||||
<i class="fa-solid fa-phone-volume op-att-icon"></i>
|
||||
<span class="op-att-label"></span>
|
||||
<button type="button" class="op-att-btn op-att-complete" onclick="complete_attended_transfer()">
|
||||
<i class="fa-solid fa-check"></i> <?= htmlspecialchars($text['button-complete_transfer'] ?? 'Complete Transfer') ?>
|
||||
</button>
|
||||
<button type="button" class="op-att-btn op-att-cancel" onclick="cancel_attended_transfer()">
|
||||
<i class="fa-solid fa-xmark"></i> <?= htmlspecialchars($text['button-cancel_transfer'] ?? 'Cancel') ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Transfer dialog -->
|
||||
<dialog id="transfer_dialog" class="op-dialog">
|
||||
<div class="op-dialog-header">
|
||||
<h5><?= htmlspecialchars($text['label-transfer'] ?? 'Transfer Call') ?></h5>
|
||||
<button type="button" class="op-dialog-close" onclick="document.getElementById('transfer_dialog').close()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="op-dialog-body">
|
||||
<label for="transfer_destination" style="font-weight:600; display:block; margin-bottom:4px;">
|
||||
<?= htmlspecialchars($text['label-destination'] ?? 'Destination') ?>
|
||||
</label>
|
||||
<input type="text" id="transfer_destination" class="op-dialog-input" placeholder="1001" autocomplete="off">
|
||||
<input type="hidden" id="transfer_uuid">
|
||||
<input type="hidden" id="transfer_source_extension">
|
||||
</div>
|
||||
<div class="op-dialog-footer">
|
||||
<button type="button" class="op-dialog-btn op-btn-secondary" onclick="document.getElementById('transfer_dialog').close()">
|
||||
<?= htmlspecialchars($text['button-cancel'] ?? 'Cancel') ?>
|
||||
</button>
|
||||
<button type="button" class="op-dialog-btn op-btn-primary" onclick="confirm_transfer()">
|
||||
<?= htmlspecialchars($text['button-transfer'] ?? 'Transfer') ?>
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Ringing Action dialog -->
|
||||
<dialog id="ringing_action_dialog" class="op-dialog op-dialog-sm">
|
||||
<div class="op-dialog-header">
|
||||
<h5><?= htmlspecialchars($text['label-choose_action'] ?? 'Choose Action') ?></h5>
|
||||
<button type="button" class="op-dialog-close" onclick="document.getElementById('ringing_action_dialog').close()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="op-dialog-body" style="text-align:center;">
|
||||
<p id="ringing_action_description" style="margin-bottom:12px;"></p>
|
||||
<div class="op-dialog-actions">
|
||||
<button type="button" class="op-dialog-btn op-btn-success" id="ringing_action_intercept">
|
||||
<?= htmlspecialchars($text['button-intercept'] ?? 'Intercept') ?>
|
||||
</button>
|
||||
<button type="button" class="op-dialog-btn op-btn-primary" id="ringing_action_call">
|
||||
<?= htmlspecialchars($text['button-call'] ?? 'Call') ?>
|
||||
</button>
|
||||
<button type="button" class="op-dialog-btn op-btn-info" id="ringing_action_eavesdrop">
|
||||
<?= htmlspecialchars($text['label-eavesdrop'] ?? 'Eavesdrop') ?>
|
||||
</button>
|
||||
<button type="button" class="op-dialog-btn op-btn-secondary" onclick="document.getElementById('ringing_action_dialog').close()">
|
||||
<?= htmlspecialchars($text['button-cancel'] ?? 'Cancel') ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<br><br>
|
||||
|
||||
|
||||
@@ -172,7 +172,11 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
const permission_map = [
|
||||
// Call actions
|
||||
'hangup' => 'operator_panel_hangup',
|
||||
'hangup_caller' => 'operator_panel_hangup',
|
||||
'transfer' => 'operator_panel_manage',
|
||||
'transfer_attended' => 'operator_panel_transfer_attended',
|
||||
'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',
|
||||
@@ -180,6 +184,7 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
'recording_state' => 'operator_panel_record',
|
||||
'registrations_state' => 'operator_panel_view',
|
||||
'originate' => 'operator_panel_originate',
|
||||
'intercept' => 'operator_panel_manage',
|
||||
// Conference member actions
|
||||
'mute' => 'operator_panel_manage',
|
||||
'unmute' => 'operator_panel_manage',
|
||||
@@ -262,6 +267,7 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
$this->on_topic('calls_active', [$this, 'request_calls_active']);
|
||||
$this->on_topic('conferences_active', [$this, 'request_conferences_active']);
|
||||
$this->on_topic('agents_active', [$this, 'request_agents_active']);
|
||||
$this->on_topic('parked_active', [$this, 'request_parked_active']);
|
||||
// Action handler (all mutations)
|
||||
$this->on_topic('action', [$this, 'handle_action']);
|
||||
// Keep-alive
|
||||
@@ -443,7 +449,7 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
$this->broadcast_call_event($event_message, $topic);
|
||||
break;
|
||||
|
||||
case 'valet_parking':
|
||||
case 'valet_parking::info':
|
||||
$this->broadcast_call_event($event_message, 'valet_info');
|
||||
break;
|
||||
|
||||
@@ -528,7 +534,7 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
$user_status_map = [];
|
||||
try {
|
||||
$t_us0 = microtime(true);
|
||||
$sql = "SELECT e.extension, eu.user_uuid, COALESCE(u.user_status, 'Logged Out') AS user_status "
|
||||
$sql = "SELECT e.extension, eu.user_uuid, COALESCE(u.user_status, '') AS user_status "
|
||||
. "FROM v_extensions AS e "
|
||||
. "LEFT JOIN v_domains AS d ON e.domain_uuid = d.domain_uuid "
|
||||
. "LEFT JOIN ("
|
||||
@@ -552,7 +558,7 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
if ($ext_num === '') continue;
|
||||
$user_status_map[$ext_num] = [
|
||||
'user_uuid' => $row['user_uuid'] ?? null,
|
||||
'user_status' => $row['user_status'] ?? 'Logged Out',
|
||||
'user_status' => $row['user_status'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -568,6 +574,7 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
$registered_map = [];
|
||||
try {
|
||||
$t_reg0 = microtime(true);
|
||||
$normalized_domain_name = preg_replace('/:\d+$/', '', (string) $domain_name);
|
||||
$this->debug('extensions_active trace [step4] fetching registrations via show registrations as json');
|
||||
$reg_json = trim(event_socket::api('show registrations as json'));
|
||||
$this->debug('extensions_active trace [step4] registrations api returned: bytes=' . strlen($reg_json)
|
||||
@@ -576,9 +583,16 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
$reg_data = json_decode($reg_json, true);
|
||||
if (is_array($reg_data) && !empty($reg_data['rows'])) {
|
||||
foreach ($reg_data['rows'] as $row) {
|
||||
$ext_num = $row['reg_user'] ?? '';
|
||||
$reg_domain = $row['realm'] ?? '';
|
||||
if (!empty($ext_num) && $reg_domain === $domain_name) {
|
||||
$ext_num = trim((string) ($row['reg_user'] ?? ''));
|
||||
$reg_domain = preg_replace('/:\d+$/', '', trim((string) ($row['realm'] ?? '')));
|
||||
if (strpos($ext_num, '@') !== false) {
|
||||
[$ext_num, $parsed_domain] = array_pad(explode('@', $ext_num, 2), 2, '');
|
||||
$ext_num = trim((string) $ext_num);
|
||||
if ($reg_domain === '' && $parsed_domain !== '') {
|
||||
$reg_domain = preg_replace('/:\d+$/', '', trim((string) $parsed_domain));
|
||||
}
|
||||
}
|
||||
if ($ext_num !== '' && $reg_domain === $normalized_domain_name) {
|
||||
$registered_map[$ext_num] = ($registered_map[$ext_num] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
@@ -717,6 +731,83 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
websocket_client::send($this->ws_client->socket(), $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to a parked_active snapshot request.
|
||||
*
|
||||
* Uses valet_info to enumerate current parked slots for the subscriber's domain
|
||||
* and enriches each parked UUID with caller details.
|
||||
*
|
||||
* @param websocket_message $message
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function request_parked_active(websocket_message $message): void {
|
||||
$this->debug('parked_active snapshot requested');
|
||||
|
||||
$payload = $message->payload();
|
||||
$domain_name = $payload['domain_name'] ?? '';
|
||||
$parked = [];
|
||||
|
||||
if ($domain_name !== '') {
|
||||
$valet_info = event_socket::api('valet_info park@' . $domain_name);
|
||||
if ($valet_info !== false && is_string($valet_info)) {
|
||||
$matches = [];
|
||||
preg_match_all('/<extension uuid="(.*?)">(.*?)<\/extension>/s', $valet_info, $matches, PREG_SET_ORDER);
|
||||
foreach ($matches as $row) {
|
||||
$uuid = $row[1] ?? '';
|
||||
$extension = trim((string)($row[2] ?? ''));
|
||||
if ($uuid === '' || $extension === '') continue;
|
||||
|
||||
$caller_name = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' caller_id_name'));
|
||||
if ($caller_name === '_undef_') $caller_name = '';
|
||||
$caller_number = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' caller_id_number'));
|
||||
if ($caller_number === '_undef_') $caller_number = '';
|
||||
$parked_by = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' referred_by_user'));
|
||||
if ($parked_by === '' || $parked_by === '_undef_') {
|
||||
$parked_by = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' valet_parking_orbit_exten'));
|
||||
}
|
||||
if ($parked_by === '_undef_') $parked_by = '';
|
||||
$original_destination = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' destination_number'));
|
||||
if ($original_destination === '_undef_') $original_destination = '';
|
||||
$created_epoch = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' caller_channel_created_time'));
|
||||
if ($created_epoch === '' || $created_epoch === '_undef_') {
|
||||
// Fall back to start_uepoch (microseconds) or start_epoch (seconds)
|
||||
$created_epoch = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' start_uepoch'));
|
||||
if ($created_epoch === '' || $created_epoch === '_undef_') {
|
||||
$created_epoch = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' start_epoch'));
|
||||
if ($created_epoch === '_undef_') $created_epoch = '';
|
||||
}
|
||||
}
|
||||
|
||||
$parked[] = [
|
||||
'unique_id' => $uuid,
|
||||
'event_name' => 'valet_parking::snapshot',
|
||||
'action' => 'hold',
|
||||
'valet_extension' => $extension,
|
||||
'caller_caller_id_name' => $caller_name,
|
||||
'caller_caller_id_number' => $caller_number,
|
||||
'caller_destination_number' => $original_destination,
|
||||
'variable_referred_by_user' => $parked_by,
|
||||
'caller_channel_created_time' => $created_epoch,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$response = new websocket_message();
|
||||
$response
|
||||
->payload($parked)
|
||||
->service_name(self::get_service_name())
|
||||
->topic('parked_active')
|
||||
->status_string('ok')
|
||||
->status_code(200)
|
||||
->request_id($message->request_id())
|
||||
->resource_id($message->resource_id())
|
||||
;
|
||||
|
||||
websocket_client::send($this->ws_client->socket(), $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to a conferences_active snapshot request.
|
||||
*
|
||||
@@ -842,7 +933,7 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->execute_action($action, $payload, $permissions);
|
||||
$result = $this->execute_action($action, $payload);
|
||||
$status_message = isset($result['message']) ? (string)$result['message'] : '';
|
||||
$success = (bool)($result['success'] ?? false);
|
||||
$extra_payload = $result;
|
||||
@@ -867,6 +958,9 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
$member_id = $payload['member_id'] ?? '';
|
||||
$direction = $payload['direction'] ?? '';
|
||||
|
||||
// Debug action execution attempt with all relevant parameters
|
||||
$this->debug("Executing action: $action, uuid: $uuid, destination: $destination, context: $context, conference_name: $conference_name, member_id: $member_id, direction: $direction");
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'hangup':
|
||||
@@ -876,11 +970,28 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
event_socket::api("uuid_kill $uuid");
|
||||
return ['success' => true, 'message' => 'Call terminated'];
|
||||
|
||||
case 'hangup_caller':
|
||||
if (empty($uuid)) {
|
||||
return ['success' => false, 'message' => 'UUID required'];
|
||||
}
|
||||
// Find the A-leg (caller) from the B-leg UUID
|
||||
$a_leg = trim((string)event_socket::api("uuid_getvar $uuid other_leg_unique_id"));
|
||||
if (empty($a_leg) || stripos($a_leg, '-ERR') !== false || $a_leg === '_undef_') {
|
||||
$a_leg = trim((string)event_socket::api("uuid_getvar $uuid signal_bond"));
|
||||
}
|
||||
if (!empty($a_leg) && stripos($a_leg, '-ERR') === false && $a_leg !== '_undef_') {
|
||||
event_socket::api("uuid_kill $a_leg");
|
||||
$this->info("Hangup caller: killed A-leg $a_leg (B-leg was $uuid)");
|
||||
return ['success' => true, 'message' => 'Caller terminated'];
|
||||
}
|
||||
// Fallback: kill the provided UUID
|
||||
event_socket::api("uuid_kill $uuid");
|
||||
return ['success' => true, 'message' => 'Call terminated'];
|
||||
|
||||
case 'transfer':
|
||||
if (empty($uuid) || empty($destination)) {
|
||||
return ['success' => false, 'message' => 'UUID and destination required'];
|
||||
}
|
||||
// Sanitize destination: digits, *, #, + only
|
||||
if (!preg_match('/^[0-9*#+]+$/', $destination)) {
|
||||
return ['success' => false, 'message' => 'Invalid destination'];
|
||||
}
|
||||
@@ -888,6 +999,85 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
event_socket::api("uuid_transfer $uuid {$bleg}$destination XML $context");
|
||||
return ['success' => true, 'message' => 'Call transferred'];
|
||||
|
||||
case 'transfer_attended':
|
||||
if (empty($uuid) || empty($destination)) {
|
||||
return ['success' => false, 'message' => 'UUID and destination required'];
|
||||
}
|
||||
if (!preg_match('/^[0-9*#+]+$/', $destination)) {
|
||||
return ['success' => false, 'message' => 'Invalid destination'];
|
||||
}
|
||||
$xfer_domain = $domain_name !== '' ? $domain_name : $context;
|
||||
$reply = trim((string)event_socket::api("uuid_broadcast $uuid att_xfer::user/$destination@$xfer_domain both"));
|
||||
$this->debug("transfer_attended reply: $reply");
|
||||
if (stripos($reply, '-ERR') !== false) {
|
||||
return ['success' => false, 'message' => $reply];
|
||||
}
|
||||
return ['success' => true, 'message' => 'Attended transfer started'];
|
||||
case 'transfer_attended_complete':
|
||||
// Attended transfer — Step 2: Complete the transfer
|
||||
// Bridge the parked caller to the destination (other side of operator's current call).
|
||||
$parked_uuid = $payload['parked_uuid'] ?? '';
|
||||
$operator_uuid_val = $payload['operator_uuid'] ?? '';
|
||||
if (empty($parked_uuid) || empty($operator_uuid_val)) {
|
||||
return ['success' => false, 'message' => 'parked_uuid and operator_uuid required'];
|
||||
}
|
||||
|
||||
// Find the destination's channel (other side of operator's consultation call)
|
||||
$dest_uuid = trim((string)event_socket::api("uuid_getvar $operator_uuid_val other_leg_unique_id"));
|
||||
if (empty($dest_uuid) || stripos($dest_uuid, '-ERR') !== false || $dest_uuid === '_undef_') {
|
||||
$dest_uuid = trim((string)event_socket::api("uuid_getvar $operator_uuid_val signal_bond"));
|
||||
}
|
||||
|
||||
if (!empty($dest_uuid) && stripos($dest_uuid, '-ERR') === false && $dest_uuid !== '_undef_') {
|
||||
// Destination answered — bridge parked caller to destination
|
||||
event_socket::api("uuid_bridge $parked_uuid $dest_uuid");
|
||||
// Disconnect operator cleanly
|
||||
event_socket::api("uuid_kill $operator_uuid_val");
|
||||
$this->info("Attended transfer complete: bridged $parked_uuid to $dest_uuid");
|
||||
return ['success' => true, 'message' => 'Transfer completed'];
|
||||
}
|
||||
|
||||
// Destination not yet answered — blind-transfer the parked caller instead
|
||||
$dest_ext = $payload['destination'] ?? '';
|
||||
if (!empty($dest_ext) && preg_match('/^[0-9*#+]+$/', $dest_ext)) {
|
||||
event_socket::api("uuid_kill $operator_uuid_val");
|
||||
$xfer_context = $payload['context'] ?? ($domain_name !== '' ? $domain_name : 'default');
|
||||
event_socket::api("uuid_transfer $parked_uuid $dest_ext XML $xfer_context");
|
||||
$this->info("Attended transfer complete (dest not answered): blind-transferred $parked_uuid to $dest_ext");
|
||||
return ['success' => true, 'message' => 'Transfer completed (destination still ringing)'];
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => 'Cannot find destination channel'];
|
||||
|
||||
case 'transfer_attended_cancel':
|
||||
// Attended transfer — Cancel: hang up consultation, reconnect caller to operator
|
||||
$parked_uuid = $payload['parked_uuid'] ?? '';
|
||||
$operator_uuid_val = $payload['operator_uuid'] ?? '';
|
||||
$source_ext = $payload['source_extension'] ?? '';
|
||||
if (empty($parked_uuid)) {
|
||||
return ['success' => false, 'message' => 'parked_uuid required'];
|
||||
}
|
||||
|
||||
// Kill the operator's consultation call (also terminates the destination leg)
|
||||
if (!empty($operator_uuid_val)) {
|
||||
event_socket::api("uuid_kill $operator_uuid_val");
|
||||
}
|
||||
|
||||
// Reconnect the parked caller back to the operator's extension
|
||||
if (!empty($source_ext) && !empty($domain_name)) {
|
||||
$originate_cmd = "bgapi originate user/{$source_ext}@{$domain_name} &bridge({$parked_uuid})";
|
||||
$reply = trim((string)event_socket::api($originate_cmd));
|
||||
if (stripos($reply, '-ERR') === false) {
|
||||
$this->info("Attended transfer cancelled: reconnecting $parked_uuid via $source_ext");
|
||||
return ['success' => true, 'message' => 'Transfer cancelled, reconnecting'];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: could not reconnect — just kill the parked call
|
||||
event_socket::api("uuid_kill $parked_uuid");
|
||||
$this->warning("Attended transfer cancelled: could not reconnect, killed parked call $parked_uuid");
|
||||
return ['success' => true, 'message' => 'Transfer cancelled'];
|
||||
|
||||
case 'eavesdrop':
|
||||
if (empty($uuid) || empty($destination)) {
|
||||
return ['success' => false, 'message' => 'UUID and destination extension required'];
|
||||
@@ -992,14 +1182,22 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
if (empty($domain_name)) {
|
||||
return ['success' => false, 'message' => 'domain_name required'];
|
||||
}
|
||||
$normalized_domain_name = preg_replace('/:\d+$/', '', (string) $domain_name);
|
||||
$states = [];
|
||||
$reg_json = trim((string) event_socket::api('show registrations as json'));
|
||||
$reg_data = json_decode($reg_json, true);
|
||||
if (is_array($reg_data) && !empty($reg_data['rows']) && is_array($reg_data['rows'])) {
|
||||
foreach ($reg_data['rows'] as $row) {
|
||||
$ext_num = trim((string)($row['reg_user'] ?? ''));
|
||||
$reg_domain = trim((string)($row['realm'] ?? ''));
|
||||
if ($ext_num === '' || $reg_domain !== $domain_name) continue;
|
||||
$ext_num = trim((string) ($row['reg_user'] ?? ''));
|
||||
$reg_domain = preg_replace('/:\d+$/', '', trim((string) ($row['realm'] ?? '')));
|
||||
if (strpos($ext_num, '@') !== false) {
|
||||
[$ext_num, $parsed_domain] = array_pad(explode('@', $ext_num, 2), 2, '');
|
||||
$ext_num = trim((string) $ext_num);
|
||||
if ($reg_domain === '' && $parsed_domain !== '') {
|
||||
$reg_domain = preg_replace('/:\d+$/', '', trim((string) $parsed_domain));
|
||||
}
|
||||
}
|
||||
if ($ext_num === '' || $reg_domain !== $normalized_domain_name) continue;
|
||||
$states[$ext_num] = ($states[$ext_num] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
@@ -1068,6 +1266,40 @@ class operator_panel_service extends base_websocket_system_service implements we
|
||||
}
|
||||
return ['success' => true, 'message' => 'Volume updated'];
|
||||
|
||||
case 'intercept':
|
||||
if (empty($uuid) || empty($destination)) {
|
||||
return ['success' => false, 'message' => 'UUID and destination extension required'];
|
||||
}
|
||||
$dest_ext = $payload['destination_extension'] ?? $destination;
|
||||
if (!preg_match('/^[0-9*#+]+$/', $dest_ext)) {
|
||||
return ['success' => false, 'message' => 'Invalid destination extension'];
|
||||
}
|
||||
if (empty($domain_name)) {
|
||||
return ['success' => false, 'message' => 'domain_name required'];
|
||||
}
|
||||
// Find the A-leg (caller) by querying the B-leg's other_leg_unique_id
|
||||
$a_leg = trim((string)event_socket::api("uuid_getvar $uuid other_leg_unique_id"));
|
||||
if (empty($a_leg) || stripos($a_leg, '-ERR') !== false || $a_leg === '_undef_') {
|
||||
$a_leg = trim((string)event_socket::api("uuid_getvar $uuid bridge_uuid"));
|
||||
}
|
||||
if (empty($a_leg) || stripos($a_leg, '-ERR') !== false || $a_leg === '_undef_') {
|
||||
$a_leg = trim((string)event_socket::api("uuid_getvar $uuid signal_bond"));
|
||||
}
|
||||
if (!empty($a_leg) && stripos($a_leg, '-ERR') === false && $a_leg !== '_undef_') {
|
||||
// Transfer the A-leg to the interceptor's extension
|
||||
$reply = trim((string)event_socket::api("uuid_transfer $a_leg $dest_ext XML $context"));
|
||||
$this->info("Intercept via A-leg transfer: a_leg=$a_leg dest=$dest_ext@$domain_name");
|
||||
} else {
|
||||
// Last resort: transfer the B-leg itself to the interceptor
|
||||
$reply = trim((string)event_socket::api("uuid_transfer $uuid $dest_ext XML $context"));
|
||||
$this->info("Intercept via B-leg transfer: uuid=$uuid dest=$dest_ext@$domain_name");
|
||||
}
|
||||
$this->debug("Intercept command reply: $reply");
|
||||
if (stripos($reply, '-ERR') !== false) {
|
||||
return ['success' => false, 'message' => $reply];
|
||||
}
|
||||
return ['success' => true, 'message' => 'Call intercepted'];
|
||||
|
||||
case 'originate':
|
||||
$source = $payload['source'] ?? '';
|
||||
$dest = $payload['destination'] ?? '';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Intercept">
|
||||
<defs>
|
||||
<linearGradient id="ig1" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#68d391"/>
|
||||
<stop offset="100%" stop-color="#38a169"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Phone handset -->
|
||||
<path d="M18 46 C10 42, 8 34, 12 26 L16 28 C14 34, 16 38, 20 42 Z" fill="url(#ig1)"/>
|
||||
<path d="M46 18 C42 10, 34 8, 26 12 L28 16 C34 14, 38 16, 42 20 Z" fill="url(#ig1)"/>
|
||||
<line x1="18" y1="46" x2="46" y2="18" stroke="url(#ig1)" stroke-width="5" stroke-linecap="round"/>
|
||||
<!-- Arrow pointing down-left indicating "grab" -->
|
||||
<polyline points="28,50 18,50 18,40" fill="none" stroke="#2d3748" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="18" y1="50" x2="34" y2="34" stroke="#2d3748" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 925 B |
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Reject">
|
||||
<defs>
|
||||
<linearGradient id="rg1" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#fc8181"/>
|
||||
<stop offset="100%" stop-color="#e53e3e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Phone handset rotated to "hang up" position -->
|
||||
<path d="M12 38 C12 28, 18 22, 32 20 C46 22, 52 28, 52 38 L48 40 C46 34, 40 30, 32 28 C24 30, 18 34, 16 40 Z" fill="url(#rg1)"/>
|
||||
<!-- X mark overlay -->
|
||||
<line x1="22" y1="42" x2="42" y2="54" stroke="#c53030" stroke-width="4" stroke-linecap="round"/>
|
||||
<line x1="42" y1="42" x2="22" y2="54" stroke="#c53030" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 743 B |
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user