diff --git a/CLAUDE.md b/CLAUDE.md index 2131355..72dbf6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **THIS PLUGIN RUNS ON A REMOTE SERVER IN A DOCKER CONTAINER - NOT LOCALLY** - **Production Server Path**: `/home/shadowdao/public_html/wp-content/plugins/twilio-wp-plugin/` -- **Website URL**: `https://www.streamers.channel/` +- **Website URL**: `https://phone.cloud-hosting.io/` - **Development Path**: `/home/jknapp/code/twilio-wp-plugin/` - **Deployment Method**: Files synced via rsync from development to Docker container @@ -38,39 +38,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Test Agent Number**: `+19095737372` - **Fake Test Number**: `+19512345678` (DO NOT SEND SMS TO THIS) -## ๐Ÿงช Testing Procedures - -### โœ… Working: Direct Twilio Test -```bash -# SSH into server -cd /home/shadowdao/public_html/wp-content/plugins/twilio-wp-plugin/ -php test-twilio-direct.php send -``` -**Result**: SMS sends successfully via Twilio SDK - -### โŒ Not Working: WordPress Admin SMS -- Admin pages load and show success messages -- But SMS doesn't actually send -- No PHP errors logged -- No Twilio API calls recorded - ### Webhook URLs: - **SMS**: `https://www.streamers.channel/wp-json/twilio-webhook/v1/sms` - **Voice**: `https://www.streamers.channel/wp-json/twilio-webhook/v1/voice` -## Known Issues & Solutions - -### Issue: SMS not sending from WordPress admin -**Symptoms**: -- Direct PHP test works -- WordPress admin shows success but no SMS sent -- No errors in logs - -**Possible Causes**: -1. WordPress execution context differs from CLI -2. Silent failures in WordPress AJAX/admin context -3. Plugin initialization issues in admin context - ## Project Overview This is a comprehensive WordPress plugin for Twilio voice and SMS integration, featuring: @@ -312,4 +283,5 @@ composer require twilio/sdk - **Phone Validation**: Auto-formats US numbers to +1 format - **Duplicate Prevention**: Checks phone numbers across all users -This plugin provides a complete call center solution with professional-grade features suitable for business use. \ No newline at end of file +This plugin provides a complete call center solution with professional-grade features suitable for business use. +- our production url is https://phone.cloud-hosting.io \ No newline at end of file diff --git a/admin/class-twp-admin.php b/admin/class-twp-admin.php index 1ade072..210c5e0 100644 --- a/admin/class-twp-admin.php +++ b/admin/class-twp-admin.php @@ -1941,10 +1941,18 @@ class TWP_Admin { * Display voicemails page */ public function display_voicemails_page() { + // Get the active tab + $active_tab = isset($_GET['tab']) ? sanitize_text_field($_GET['tab']) : 'voicemails'; ?>
-

Voicemails

+

Voicemails & Recordings

+ + +
+ + ['administrator', 'twp_agent']]); + foreach ($users as $user) { + echo ''; + } + ?> + + + + + + + + +
+ +
+
+

Total Recordings

+
+ prefix . 'twp_call_recordings'; + echo $wpdb->get_var("SELECT COUNT(*) FROM $recordings_table WHERE status = 'completed'"); + ?> +
+
+ +
+

Today

+
+ get_var("SELECT COUNT(*) FROM $recordings_table WHERE DATE(started_at) = CURDATE()"); + ?> +
+
+ +
+

Total Duration

+
+ get_var("SELECT SUM(duration) FROM $recordings_table"); + echo $total_seconds ? round($total_seconds / 60) . ' min' : '0 min'; + ?> +
+
+
+ + + + + + + + + + + + + + + + + +
Date/TimeFromToAgentDurationActions
Loading recordings...
+
+ + + verify_ajax_nonce()) { + wp_send_json_error('Invalid nonce'); + return; + } + + $call_sid = sanitize_text_field($_POST['call_sid']); + $hold = filter_var($_POST['hold'], FILTER_VALIDATE_BOOLEAN); + + try { + $twilio = new TWP_Twilio_API(); + $client = $twilio->get_client(); + + if ($hold) { + // Place call on hold with music + $twiml = new \Twilio\TwiML\VoiceResponse(); + $twiml->play('https://api.twilio.com/cowbell.mp3', ['loop' => 0]); + + $call = $client->calls($call_sid)->update([ + 'twiml' => $twiml->asXML() + ]); + } else { + // Resume call by redirecting back to conference or original context + $call = $client->calls($call_sid)->update([ + 'url' => home_url('/wp-json/twilio-webhook/v1/resume-call'), + 'method' => 'POST' + ]); + } + + wp_send_json_success(['message' => $hold ? 'Call on hold' : 'Call resumed']); + } catch (Exception $e) { + wp_send_json_error('Failed to toggle hold: ' . $e->getMessage()); + } + } + + /** + * AJAX handler for transferring a call + */ + public function ajax_transfer_call() { + if (!$this->verify_ajax_nonce()) { + wp_send_json_error('Invalid nonce'); + return; + } + + $call_sid = sanitize_text_field($_POST['call_sid']); + $agent_number = sanitize_text_field($_POST['agent_number']); + + // Validate phone number format + if (!preg_match('/^\+?[1-9]\d{1,14}$/', $agent_number)) { + wp_send_json_error('Invalid phone number format'); + return; + } + + try { + $twilio = new TWP_Twilio_API(); + $client = $twilio->get_client(); + + // Create TwiML to transfer the call + $twiml = new \Twilio\TwiML\VoiceResponse(); + $twiml->say('Transferring your call. Please hold.'); + $twiml->dial($agent_number); + + // Update the call with the transfer TwiML + $call = $client->calls($call_sid)->update([ + 'twiml' => $twiml->asXML() + ]); + + wp_send_json_success(['message' => 'Call transferred successfully']); + } catch (Exception $e) { + wp_send_json_error('Failed to transfer call: ' . $e->getMessage()); + } + } + + /** + * AJAX handler for requeuing a call + */ + public function ajax_requeue_call() { + if (!$this->verify_ajax_nonce()) { + wp_send_json_error('Invalid nonce'); + return; + } + + $call_sid = sanitize_text_field($_POST['call_sid']); + $queue_id = intval($_POST['queue_id']); + + // Validate queue exists + global $wpdb; + $queue_table = $wpdb->prefix . 'twp_call_queues'; + $queue = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $queue_table WHERE id = %d", + $queue_id + )); + + if (!$queue) { + wp_send_json_error('Invalid queue'); + return; + } + + try { + $twilio = new TWP_Twilio_API(); + $client = $twilio->get_client(); + + // Create TwiML to enqueue the call + $twiml = new \Twilio\TwiML\VoiceResponse(); + $twiml->say('Placing you back in the queue. Please hold.'); + $enqueue = $twiml->enqueue($queue->queue_name); + $enqueue->waitUrl(home_url('/wp-json/twilio-webhook/v1/queue-wait')); + + // Update the call with the requeue TwiML + $call = $client->calls($call_sid)->update([ + 'twiml' => $twiml->asXML() + ]); + + // Add call to our database queue tracking + $calls_table = $wpdb->prefix . 'twp_queued_calls'; + $wpdb->insert($calls_table, [ + 'queue_id' => $queue_id, + 'call_sid' => $call_sid, + 'from_number' => $call->from, + 'enqueued_at' => current_time('mysql'), + 'status' => 'waiting' + ]); + + wp_send_json_success(['message' => 'Call requeued successfully']); + } catch (Exception $e) { + wp_send_json_error('Failed to requeue call: ' . $e->getMessage()); + } + } + + /** + * AJAX handler for starting call recording + */ + public function ajax_start_recording() { + if (!$this->verify_ajax_nonce()) { + wp_send_json_error('Invalid nonce'); + return; + } + + $call_sid = sanitize_text_field($_POST['call_sid']); + $user_id = get_current_user_id(); + + try { + $twilio = new TWP_Twilio_API(); + $client = $twilio->get_client(); + + // Start recording the call + $recording = $client->calls($call_sid)->recordings->create([ + 'recordingStatusCallback' => home_url('/wp-json/twilio-webhook/v1/recording-status'), + 'recordingStatusCallbackEvent' => ['completed', 'absent'], + 'recordingChannels' => 'dual' + ]); + + // Store recording info in database + global $wpdb; + $recordings_table = $wpdb->prefix . 'twp_call_recordings'; + + // Get call details + $call = $client->calls($call_sid)->fetch(); + + $wpdb->insert($recordings_table, [ + 'call_sid' => $call_sid, + 'recording_sid' => $recording->sid, + 'from_number' => $call->from, + 'to_number' => $call->to, + 'agent_id' => $user_id, + 'status' => 'recording', + 'started_at' => current_time('mysql') + ]); + + wp_send_json_success([ + 'message' => 'Recording started', + 'recording_sid' => $recording->sid + ]); + } catch (Exception $e) { + wp_send_json_error('Failed to start recording: ' . $e->getMessage()); + } + } + + /** + * AJAX handler for stopping call recording + */ + public function ajax_stop_recording() { + if (!$this->verify_ajax_nonce()) { + wp_send_json_error('Invalid nonce'); + return; + } + + $call_sid = sanitize_text_field($_POST['call_sid']); + $recording_sid = sanitize_text_field($_POST['recording_sid']); + + try { + $twilio = new TWP_Twilio_API(); + $client = $twilio->get_client(); + + // Stop the recording + $recording = $client->calls($call_sid) + ->recordings($recording_sid) + ->update(['status' => 'stopped']); + + // Update database + global $wpdb; + $recordings_table = $wpdb->prefix . 'twp_call_recordings'; + + $wpdb->update( + $recordings_table, + [ + 'status' => 'completed', + 'ended_at' => current_time('mysql') + ], + ['recording_sid' => $recording_sid] + ); + + wp_send_json_success(['message' => 'Recording stopped']); + } catch (Exception $e) { + wp_send_json_error('Failed to stop recording: ' . $e->getMessage()); + } + } + + /** + * AJAX handler for getting call recordings + */ + public function ajax_get_call_recordings() { + if (!$this->verify_ajax_nonce()) { + wp_send_json_error('Invalid nonce'); + return; + } + + global $wpdb; + $recordings_table = $wpdb->prefix . 'twp_call_recordings'; + $user_id = get_current_user_id(); + + // Build query based on user permissions + if (current_user_can('manage_options')) { + // Admins can see all recordings + $recordings = $wpdb->get_results(" + SELECT r.*, u.display_name as agent_name + FROM $recordings_table r + LEFT JOIN {$wpdb->users} u ON r.agent_id = u.ID + ORDER BY r.started_at DESC + LIMIT 100 + "); + } else { + // Regular users see only their recordings + $recordings = $wpdb->get_results($wpdb->prepare(" + SELECT r.*, u.display_name as agent_name + FROM $recordings_table r + LEFT JOIN {$wpdb->users} u ON r.agent_id = u.ID + WHERE r.agent_id = %d + ORDER BY r.started_at DESC + LIMIT 50 + ", $user_id)); + } + + // Format recordings for display + $formatted_recordings = []; + foreach ($recordings as $recording) { + $formatted_recordings[] = [ + 'id' => $recording->id, + 'call_sid' => $recording->call_sid, + 'recording_sid' => $recording->recording_sid, + 'from_number' => $recording->from_number, + 'to_number' => $recording->to_number, + 'agent_name' => $recording->agent_name, + 'duration' => $recording->duration, + 'started_at' => $recording->started_at, + 'recording_url' => $recording->recording_url, + 'has_recording' => !empty($recording->recording_url) + ]; + } + + wp_send_json_success($formatted_recordings); + } + + /** + * AJAX handler for deleting a recording (admin only) + */ + public function ajax_delete_recording() { + if (!$this->verify_ajax_nonce()) { + wp_send_json_error('Invalid nonce'); + return; + } + + // Check admin permissions + if (!current_user_can('manage_options')) { + wp_send_json_error('You do not have permission to delete recordings'); + return; + } + + $recording_id = intval($_POST['recording_id']); + + global $wpdb; + $recordings_table = $wpdb->prefix . 'twp_call_recordings'; + + // Get recording details first + $recording = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $recordings_table WHERE id = %d", + $recording_id + )); + + if (!$recording) { + wp_send_json_error('Recording not found'); + return; + } + + // Delete from Twilio if we have a recording SID + if ($recording->recording_sid) { + try { + $twilio = new TWP_Twilio_API(); + $client = $twilio->get_client(); + + // Try to delete from Twilio + $client->recordings($recording->recording_sid)->delete(); + } catch (Exception $e) { + // Log error but continue with local deletion + error_log('TWP: Failed to delete recording from Twilio: ' . $e->getMessage()); + } + } + + // Delete from database + $result = $wpdb->delete( + $recordings_table, + ['id' => $recording_id], + ['%d'] + ); + + if ($result === false) { + wp_send_json_error('Failed to delete recording from database'); + } else { + wp_send_json_success(['message' => 'Recording deleted successfully']); + } + } + + /** + * AJAX handler for getting online agents for transfer + */ + public function ajax_get_online_agents() { + if (!$this->verify_ajax_nonce()) { + wp_send_json_error('Invalid nonce'); + return; + } + + global $wpdb; + $status_table = $wpdb->prefix . 'twp_agent_status'; + + // Get all agents with their status + $agents = $wpdb->get_results(" + SELECT + u.ID, + u.display_name, + u.user_email, + um.meta_value as phone_number, + s.status, + s.current_call_sid, + CASE + WHEN s.status = 'available' AND s.current_call_sid IS NULL THEN 1 + WHEN s.status = 'available' AND s.current_call_sid IS NOT NULL THEN 2 + WHEN s.status = 'busy' THEN 3 + ELSE 4 + END as priority + FROM {$wpdb->users} u + LEFT JOIN {$wpdb->usermeta} um ON u.ID = um.user_id AND um.meta_key = 'twp_phone_number' + LEFT JOIN $status_table s ON u.ID = s.user_id + WHERE u.ID != %d + ORDER BY priority, u.display_name + ", get_current_user_id()); + + $formatted_agents = []; + foreach ($agents as $agent) { + $transfer_method = null; + $transfer_value = null; + + // Determine transfer method + if ($agent->phone_number) { + $transfer_method = 'phone'; + $transfer_value = $agent->phone_number; + } elseif ($agent->status === 'available') { + $transfer_method = 'queue'; + $transfer_value = 'agent_' . $agent->ID; // User-specific queue name + } + + if ($transfer_method) { + $formatted_agents[] = [ + 'id' => $agent->ID, + 'name' => $agent->display_name, + 'email' => $agent->user_email, + 'status' => $agent->status ?: 'offline', + 'is_available' => ($agent->status === 'available' && !$agent->current_call_sid), + 'has_phone' => !empty($agent->phone_number), + 'transfer_method' => $transfer_method, + 'transfer_value' => $transfer_value + ]; + } + } + + wp_send_json_success($formatted_agents); + } + + /** + * AJAX handler for transferring call to agent queue + */ + public function ajax_transfer_to_agent_queue() { + if (!$this->verify_ajax_nonce()) { + wp_send_json_error('Invalid nonce'); + return; + } + + $call_sid = sanitize_text_field($_POST['call_sid']); + $agent_id = intval($_POST['agent_id']); + $transfer_method = sanitize_text_field($_POST['transfer_method']); + $transfer_value = sanitize_text_field($_POST['transfer_value']); + + try { + $twilio = new TWP_Twilio_API(); + $client = $twilio->get_client(); + + $twiml = new \Twilio\TwiML\VoiceResponse(); + + if ($transfer_method === 'phone') { + // Direct phone transfer + $twiml->say('Transferring your call. Please hold.'); + $twiml->dial($transfer_value); + } else { + // Queue-based transfer for web phone agents + $queue_name = 'agent_' . $agent_id; + + // Create or ensure the agent-specific queue exists in Twilio + $this->ensure_agent_queue_exists($queue_name, $agent_id); + + // Notify the agent they have an incoming transfer + $this->notify_agent_of_transfer($agent_id, $call_sid); + + $twiml->say('Transferring you to an agent. Please hold.'); + $enqueue = $twiml->enqueue($queue_name); + $enqueue->waitUrl(home_url('/wp-json/twilio-webhook/v1/queue-wait')); + } + + // Update the call with the transfer TwiML + $call = $client->calls($call_sid)->update([ + 'twiml' => $twiml->asXML() + ]); + + wp_send_json_success(['message' => 'Call transferred successfully']); + } catch (Exception $e) { + wp_send_json_error('Failed to transfer call: ' . $e->getMessage()); + } + } + + /** + * Ensure agent-specific queue exists + */ + private function ensure_agent_queue_exists($queue_name, $agent_id) { + global $wpdb; + $queues_table = $wpdb->prefix . 'twp_call_queues'; + + // Check if queue exists + $queue = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $queues_table WHERE queue_name = %s", + $queue_name + )); + + if (!$queue) { + // Create the queue + $user = get_user_by('id', $agent_id); + $wpdb->insert($queues_table, [ + 'queue_name' => $queue_name, + 'max_size' => 10, + 'timeout_seconds' => 300, + 'created_at' => current_time('mysql'), + 'updated_at' => current_time('mysql') + ]); + } + } + + /** + * Notify agent of incoming transfer + */ + private function notify_agent_of_transfer($agent_id, $call_sid) { + // Store notification in database or send real-time notification + // This could be enhanced with WebSockets or Server-Sent Events + + // For now, just log it + error_log("TWP: Notifying agent $agent_id of incoming transfer for call $call_sid"); + + // You could also update the agent's status + global $wpdb; + $status_table = $wpdb->prefix . 'twp_agent_status'; + $wpdb->update( + $status_table, + ['current_call_sid' => $call_sid], + ['user_id' => $agent_id] + ); + } + + /** + * AJAX handler for checking personal queue + */ + public function ajax_check_personal_queue() { + if (!$this->verify_ajax_nonce()) { + wp_send_json_error('Invalid nonce'); + return; + } + + $user_id = get_current_user_id(); + $queue_name = 'agent_' . $user_id; + + global $wpdb; + $queues_table = $wpdb->prefix . 'twp_call_queues'; + $calls_table = $wpdb->prefix . 'twp_queued_calls'; + + // Check if there are calls in the personal queue + $waiting_call = $wpdb->get_row($wpdb->prepare(" + SELECT qc.*, q.id as queue_id + FROM $calls_table qc + JOIN $queues_table q ON qc.queue_id = q.id + WHERE q.queue_name = %s + AND qc.status = 'waiting' + ORDER BY qc.enqueued_at ASC + LIMIT 1 + ", $queue_name)); + + if ($waiting_call) { + wp_send_json_success([ + 'has_waiting_call' => true, + 'call_sid' => $waiting_call->call_sid, + 'queue_id' => $waiting_call->queue_id, + 'from_number' => $waiting_call->from_number, + 'wait_time' => time() - strtotime($waiting_call->enqueued_at) + ]); + } else { + wp_send_json_success(['has_waiting_call' => false]); + } + } + + /** + * AJAX handler for accepting transfer call + */ + public function ajax_accept_transfer_call() { + if (!$this->verify_ajax_nonce()) { + wp_send_json_error('Invalid nonce'); + return; + } + + $call_sid = sanitize_text_field($_POST['call_sid']); + $queue_id = intval($_POST['queue_id']); + $user_id = get_current_user_id(); + + try { + $twilio = new TWP_Twilio_API(); + $client = $twilio->get_client(); + + // Connect the call to the browser phone + $call = $client->calls($call_sid)->update([ + 'url' => home_url('/wp-json/twilio-webhook/v1/browser-voice'), + 'method' => 'POST' + ]); + + // Update database to mark call as connected + global $wpdb; + $calls_table = $wpdb->prefix . 'twp_queued_calls'; + + $wpdb->update( + $calls_table, + [ + 'status' => 'connected', + 'agent_id' => $user_id + ], + ['call_sid' => $call_sid] + ); + + // Update agent status + $status_table = $wpdb->prefix . 'twp_agent_status'; + $wpdb->update( + $status_table, + ['current_call_sid' => $call_sid], + ['user_id' => $user_id] + ); + + wp_send_json_success(['message' => 'Transfer accepted']); + } catch (Exception $e) { + wp_send_json_error('Failed to accept transfer: ' . $e->getMessage()); + } + } + } \ No newline at end of file diff --git a/assets/css/browser-phone-frontend.css b/assets/css/browser-phone-frontend.css index 5c0f435..bbefe44 100644 --- a/assets/css/browser-phone-frontend.css +++ b/assets/css/browser-phone-frontend.css @@ -1,4 +1,208 @@ /* Twilio Browser Phone Frontend Styles - Mobile First */ + +/* Call Control Panel Styles */ +.twp-call-controls-panel { + margin: 15px 0; + padding: 15px; + background: #fff; + border-radius: 8px; + border: 1px solid #dee2e6; +} + +.call-control-buttons { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} + +.twp-btn-control { + padding: 10px 15px; + background: #6c757d; + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.twp-btn-control:hover { + background: #5a6268; + transform: translateY(-1px); +} + +.twp-btn-control.btn-active { + background: #007bff; +} + +.twp-btn-control.btn-recording { + background: #dc3545; + animation: recording-pulse 1.5s infinite; +} + +@keyframes recording-pulse { + 0% { opacity: 1; } + 50% { opacity: 0.7; } + 100% { opacity: 1; } +} + +/* Dialog Overlay Styles */ +.twp-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.twp-dialog { + background: white; + border-radius: 12px; + padding: 25px; + max-width: 400px; + width: 90%; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); +} + +.twp-dialog h3 { + margin: 0 0 15px 0; + font-size: 1.3rem; + color: #212529; +} + +.twp-dialog p { + margin: 0 0 20px 0; + color: #6c757d; +} + +.twp-dialog input[type="tel"], +.twp-dialog select { + width: 100%; + padding: 10px; + border: 2px solid #dee2e6; + border-radius: 6px; + font-size: 16px; + margin-bottom: 20px; +} + +.twp-dialog input[type="tel"]:focus, +.twp-dialog select:focus { + outline: none; + border-color: #007bff; +} + +.dialog-buttons { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.dialog-buttons .twp-btn { + padding: 10px 20px; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.dialog-buttons .twp-btn-primary { + background: #007bff; + color: white; + border: none; +} + +.dialog-buttons .twp-btn-primary:hover { + background: #0056b3; +} + +.dialog-buttons .twp-btn-secondary { + background: #6c757d; + color: white; + border: none; +} + +.dialog-buttons .twp-btn-secondary:hover { + background: #5a6268; +} + +/* Agent Transfer Dialog Styles */ +.twp-agent-transfer-dialog { + max-width: 500px; +} + +.agent-list { + max-height: 300px; + overflow-y: auto; + margin-bottom: 20px; + border: 1px solid #dee2e6; + border-radius: 6px; +} + +.agent-option { + padding: 12px; + border-bottom: 1px solid #e9ecef; + cursor: pointer; + transition: background 0.2s; + display: flex; + justify-content: space-between; + align-items: center; +} + +.agent-option:last-child { + border-bottom: none; +} + +.agent-option:hover:not(.offline) { + background: #f8f9fa; +} + +.agent-option.selected { + background: #e7f3ff; + border-left: 3px solid #007bff; +} + +.agent-option.offline { + opacity: 0.5; + cursor: not-allowed; +} + +.agent-info { + display: flex; + align-items: center; + gap: 10px; +} + +.agent-name { + font-weight: 500; + color: #212529; +} + +.agent-method { + font-size: 18px; +} + +.agent-status { + font-size: 14px; + white-space: nowrap; +} + +.manual-option { + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid #dee2e6; +} + +.manual-option p { + margin-bottom: 10px; + font-size: 14px; + color: #6c757d; +} .twp-browser-phone-container { max-width: 400px; margin: 0 auto; diff --git a/assets/js/browser-phone-frontend.js b/assets/js/browser-phone-frontend.js index e05366e..187ef6d 100644 --- a/assets/js/browser-phone-frontend.js +++ b/assets/js/browser-phone-frontend.js @@ -23,6 +23,11 @@ let notificationPermission = 'default'; let backgroundAlertInterval = null; let isPageVisible = true; + let isOnHold = false; + let isRecording = false; + let recordingSid = null; + let personalQueueTimer = null; + let personalQueueName = null; // Initialize when document is ready $(document).ready(function() { @@ -38,6 +43,7 @@ initVoicemailSection(); initializeNotifications(); initializePageVisibility(); + initializePersonalQueue(); }); /** @@ -271,6 +277,47 @@ hangupCall(); }); + // Call control buttons + $('#twp-hold-btn').on('click', function() { + toggleHold(); + }); + + $('#twp-transfer-btn').on('click', function() { + showTransferDialog(); + }); + + $('#twp-requeue-btn').on('click', function() { + showRequeueDialog(); + }); + + $('#twp-record-btn').on('click', function() { + toggleRecording(); + }); + + // Transfer dialog handlers + $(document).on('click', '#twp-confirm-transfer', function() { + const agentNumber = $('#twp-transfer-agent-number').val(); + if (agentNumber) { + transferCall(agentNumber); + } + }); + + $(document).on('click', '#twp-cancel-transfer', function() { + hideTransferDialog(); + }); + + // Requeue dialog handlers + $(document).on('click', '#twp-confirm-requeue', function() { + const queueId = $('#twp-requeue-select').val(); + if (queueId) { + requeueCall(queueId); + } + }); + + $(document).on('click', '#twp-cancel-requeue', function() { + hideRequeueDialog(); + }); + // Accept queue call button $('#twp-accept-queue-call').on('click', function() { acceptQueueCall(); @@ -465,12 +512,24 @@ * End call and cleanup */ function endCall() { + // Stop recording if active + if (isRecording) { + stopRecording(); + } + currentCall = null; + isOnHold = false; + isRecording = false; + recordingSid = null; stopCallTimer(); updateCallState('idle'); hideCallInfo(); $('.twp-browser-phone-container').removeClass('incoming-call'); + // Reset control buttons + $('#twp-hold-btn').text('Hold').removeClass('btn-active'); + $('#twp-record-btn').text('Record').removeClass('btn-active'); + // Restart alerts if enabled and there are waiting calls if (alertEnabled) { const hasWaitingCalls = userQueues.some(q => parseInt(q.current_waiting) > 0); @@ -671,20 +730,24 @@ function updateCallState(state) { const $callBtn = $('#twp-call-btn'); const $hangupBtn = $('#twp-hangup-btn'); + const $controlsPanel = $('#twp-call-controls-panel'); switch (state) { case 'idle': $callBtn.show().prop('disabled', false); $hangupBtn.hide(); + $controlsPanel.hide(); break; case 'connecting': case 'ringing': $callBtn.hide(); $hangupBtn.show(); + $controlsPanel.hide(); break; case 'connected': $callBtn.hide(); $hangupBtn.show(); + $controlsPanel.show(); break; } } @@ -1002,6 +1065,9 @@ if (backgroundAlertInterval) { clearInterval(backgroundAlertInterval); } + if (personalQueueTimer) { + clearInterval(personalQueueTimer); + } if (twilioDevice) { twilioDevice.destroy(); } @@ -1376,4 +1442,526 @@ // Load alert preference on init loadAlertPreference(); + /** + * Initialize personal queue for incoming transfers + */ + function initializePersonalQueue() { + if (!twp_frontend_ajax.user_id) return; + + // Set personal queue name + personalQueueName = 'agent_' + twp_frontend_ajax.user_id; + + // Start polling for incoming transfers + checkPersonalQueue(); + personalQueueTimer = setInterval(checkPersonalQueue, 3000); // Check every 3 seconds + } + + /** + * Check personal queue for incoming transfers + */ + function checkPersonalQueue() { + // Don't check if already in a call + if (currentCall) return; + + $.ajax({ + url: twp_frontend_ajax.ajax_url, + method: 'POST', + data: { + action: 'twp_check_personal_queue', + nonce: twp_frontend_ajax.nonce + }, + success: function(response) { + if (response.success && response.data.has_waiting_call) { + handleIncomingTransfer(response.data); + } + }, + error: function() { + // Silently fail - don't interrupt user + } + }); + } + + /** + * Handle incoming transfer notification + */ + function handleIncomingTransfer(data) { + // Show notification + showMessage('Incoming transfer! The call will be connected automatically.', 'info'); + + // Show browser notification + if (notificationPermission === 'granted') { + showBrowserNotification('๐Ÿ“ž Incoming Transfer!', { + body: 'A call is being transferred to you', + icon: '๐Ÿ“ž', + vibrate: [300, 200, 300], + requireInteraction: true, + tag: 'transfer-notification' + }); + } + + // Play alert sound if enabled + if (alertEnabled) { + playAlertSound(); + } + + // Auto-accept the transfer after a short delay + setTimeout(function() { + acceptTransferCall(data); + }, 2000); + } + + /** + * Accept incoming transfer call + */ + function acceptTransferCall(data) { + $.ajax({ + url: twp_frontend_ajax.ajax_url, + method: 'POST', + data: { + action: 'twp_accept_transfer_call', + call_sid: data.call_sid, + queue_id: data.queue_id, + nonce: twp_frontend_ajax.nonce + }, + success: function(response) { + if (response.success) { + showMessage('Transfer accepted, connecting...', 'success'); + } else { + showMessage('Failed to accept transfer: ' + (response.data || 'Unknown error'), 'error'); + } + }, + error: function() { + showMessage('Failed to accept transfer', 'error'); + } + }); + } + + /** + * Toggle call hold + */ + function toggleHold() { + if (!currentCall || currentCall.status() !== 'open') { + showMessage('No active call to hold', 'error'); + return; + } + + const $holdBtn = $('#twp-hold-btn'); + + $.ajax({ + url: twp_frontend_ajax.ajax_url, + method: 'POST', + data: { + action: 'twp_toggle_hold', + call_sid: currentCall.parameters.CallSid, + hold: !isOnHold, + nonce: twp_frontend_ajax.nonce + }, + success: function(response) { + if (response.success) { + isOnHold = !isOnHold; + if (isOnHold) { + $holdBtn.text('Unhold').addClass('btn-active'); + showMessage('Call placed on hold', 'info'); + } else { + $holdBtn.text('Hold').removeClass('btn-active'); + showMessage('Call resumed', 'info'); + } + } else { + showMessage('Failed to toggle hold: ' + (response.data || 'Unknown error'), 'error'); + } + }, + error: function() { + showMessage('Failed to toggle hold', 'error'); + } + }); + } + + /** + * Transfer call to another agent + */ + function transferCall(agentNumber) { + if (!currentCall || currentCall.status() !== 'open') { + showMessage('No active call to transfer', 'error'); + return; + } + + $.ajax({ + url: twp_frontend_ajax.ajax_url, + method: 'POST', + data: { + action: 'twp_transfer_call', + call_sid: currentCall.parameters.CallSid, + agent_number: agentNumber, + nonce: twp_frontend_ajax.nonce + }, + success: function(response) { + if (response.success) { + showMessage('Call transferred successfully', 'success'); + hideTransferDialog(); + // End the call on our end + if (currentCall) { + currentCall.disconnect(); + } + } else { + showMessage('Failed to transfer call: ' + (response.data || 'Unknown error'), 'error'); + } + }, + error: function() { + showMessage('Failed to transfer call', 'error'); + } + }); + } + + /** + * Requeue call to a different queue + */ + function requeueCall(queueId) { + if (!currentCall || currentCall.status() !== 'open') { + showMessage('No active call to requeue', 'error'); + return; + } + + $.ajax({ + url: twp_frontend_ajax.ajax_url, + method: 'POST', + data: { + action: 'twp_requeue_call', + call_sid: currentCall.parameters.CallSid, + queue_id: queueId, + nonce: twp_frontend_ajax.nonce + }, + success: function(response) { + if (response.success) { + showMessage('Call requeued successfully', 'success'); + hideRequeueDialog(); + // End the call on our end + if (currentCall) { + currentCall.disconnect(); + } + } else { + showMessage('Failed to requeue call: ' + (response.data || 'Unknown error'), 'error'); + } + }, + error: function() { + showMessage('Failed to requeue call', 'error'); + } + }); + } + + /** + * Toggle call recording + */ + function toggleRecording() { + if (!currentCall || currentCall.status() !== 'open') { + showMessage('No active call to record', 'error'); + return; + } + + if (isRecording) { + stopRecording(); + } else { + startRecording(); + } + } + + /** + * Start recording the current call + */ + function startRecording() { + const $recordBtn = $('#twp-record-btn'); + + $.ajax({ + url: twp_frontend_ajax.ajax_url, + method: 'POST', + data: { + action: 'twp_start_recording', + call_sid: currentCall.parameters.CallSid, + nonce: twp_frontend_ajax.nonce + }, + success: function(response) { + if (response.success) { + isRecording = true; + recordingSid = response.data.recording_sid; + $recordBtn.text('Stop Recording').addClass('btn-active btn-recording'); + showMessage('Recording started', 'success'); + } else { + showMessage('Failed to start recording: ' + (response.data || 'Unknown error'), 'error'); + } + }, + error: function() { + showMessage('Failed to start recording', 'error'); + } + }); + } + + /** + * Stop recording the current call + */ + function stopRecording() { + if (!recordingSid) return; + + const $recordBtn = $('#twp-record-btn'); + + $.ajax({ + url: twp_frontend_ajax.ajax_url, + method: 'POST', + data: { + action: 'twp_stop_recording', + call_sid: currentCall ? currentCall.parameters.CallSid : '', + recording_sid: recordingSid, + nonce: twp_frontend_ajax.nonce + }, + success: function(response) { + if (response.success) { + isRecording = false; + recordingSid = null; + $recordBtn.text('Record').removeClass('btn-active btn-recording'); + showMessage('Recording stopped', 'info'); + } else { + showMessage('Failed to stop recording: ' + (response.data || 'Unknown error'), 'error'); + } + }, + error: function() { + showMessage('Failed to stop recording', 'error'); + } + }); + } + + /** + * Show transfer dialog + */ + function showTransferDialog() { + // First load available agents + $.ajax({ + url: twp_frontend_ajax.ajax_url, + method: 'POST', + data: { + action: 'twp_get_online_agents', + nonce: twp_frontend_ajax.nonce + }, + success: function(response) { + if (response.success && response.data.length > 0) { + showAgentTransferDialog(response.data); + } else { + // Fallback to manual phone number entry + showManualTransferDialog(); + } + }, + error: function() { + // Fallback to manual phone number entry + showManualTransferDialog(); + } + }); + } + + /** + * Show agent selection transfer dialog + */ + function showAgentTransferDialog(agents) { + let agentOptions = '
'; + + agents.forEach(function(agent) { + const statusClass = agent.is_available ? 'available' : (agent.status === 'busy' ? 'busy' : 'offline'); + const statusText = agent.is_available ? '๐ŸŸข Available' : (agent.status === 'busy' ? '๐Ÿ”ด Busy' : 'โšซ Offline'); + const methodIcon = agent.has_phone ? '๐Ÿ“ฑ' : '๐Ÿ’ป'; + + agentOptions += ` +
+
+ ${agent.name} + ${methodIcon} +
+
${statusText}
+
+ `; + }); + + agentOptions += '
'; + + const dialog = ` +
+
+

Transfer Call to Agent

+

Select an agent to transfer this call to:

+ ${agentOptions} +
+

Or enter a phone number manually:

+ +
+
+ + +
+
+
+ `; + + $('body').append(dialog); + + // Handle agent selection + let selectedAgent = null; + $('.agent-option').on('click', function() { + if ($(this).hasClass('offline')) { + showMessage('Cannot transfer to offline agents', 'error'); + return; + } + + $('.agent-option').removeClass('selected'); + $(this).addClass('selected'); + + selectedAgent = { + id: $(this).data('agent-id'), + method: $(this).data('transfer-method'), + value: $(this).data('transfer-value') + }; + + $('#twp-transfer-manual-number').val(''); + $('#twp-confirm-agent-transfer').prop('disabled', false); + }); + + // Handle manual number entry + $('#twp-transfer-manual-number').on('input', function() { + const number = $(this).val().trim(); + if (number) { + $('.agent-option').removeClass('selected'); + selectedAgent = null; + $('#twp-confirm-agent-transfer').prop('disabled', false); + } else { + $('#twp-confirm-agent-transfer').prop('disabled', !selectedAgent); + } + }); + + // Handle transfer confirmation + $('#twp-confirm-agent-transfer').on('click', function() { + const manualNumber = $('#twp-transfer-manual-number').val().trim(); + + if (manualNumber) { + // Manual phone transfer + transferCall(manualNumber); + } else if (selectedAgent) { + // Agent transfer (phone or queue) + transferToAgent(selectedAgent); + } + }); + } + + /** + * Show manual transfer dialog (fallback) + */ + function showManualTransferDialog() { + const dialog = ` +
+
+

Transfer Call

+

Enter the phone number to transfer this call:

+ +
+ + +
+
+
+ `; + $('body').append(dialog); + } + + /** + * Transfer call to selected agent + */ + function transferToAgent(agent) { + if (!currentCall || currentCall.status() !== 'open') { + showMessage('No active call to transfer', 'error'); + return; + } + + $.ajax({ + url: twp_frontend_ajax.ajax_url, + method: 'POST', + data: { + action: 'twp_transfer_to_agent_queue', + call_sid: currentCall.parameters.CallSid, + agent_id: agent.id, + transfer_method: agent.method, + transfer_value: agent.value, + nonce: twp_frontend_ajax.nonce + }, + success: function(response) { + if (response.success) { + showMessage('Call transferred successfully', 'success'); + hideTransferDialog(); + // End the call on our end + if (currentCall) { + currentCall.disconnect(); + } + } else { + showMessage('Failed to transfer call: ' + (response.data || 'Unknown error'), 'error'); + } + }, + error: function() { + showMessage('Failed to transfer call', 'error'); + } + }); + } + + /** + * Hide transfer dialog + */ + function hideTransferDialog() { + $('#twp-transfer-dialog').remove(); + } + + /** + * Show requeue dialog + */ + function showRequeueDialog() { + // Load available queues first + $.ajax({ + url: twp_frontend_ajax.ajax_url, + method: 'POST', + data: { + action: 'twp_get_all_queues', + nonce: twp_frontend_ajax.nonce + }, + success: function(response) { + if (response.success && response.data.length > 0) { + let options = ''; + response.data.forEach(function(queue) { + options += ``; + }); + + const dialog = ` +
+
+

Requeue Call

+

Select a queue to transfer this call to:

+ +
+ + +
+
+
+ `; + $('body').append(dialog); + } else { + showMessage('No queues available', 'error'); + } + }, + error: function() { + showMessage('Failed to load queues', 'error'); + } + }); + } + + /** + * Hide requeue dialog + */ + function hideRequeueDialog() { + $('#twp-requeue-dialog').remove(); + } + })(jQuery); \ No newline at end of file diff --git a/includes/class-twp-activator.php b/includes/class-twp-activator.php index e44e3b0..04faf30 100644 --- a/includes/class-twp-activator.php +++ b/includes/class-twp-activator.php @@ -44,7 +44,8 @@ class TWP_Activator { 'twp_agent_groups', 'twp_group_members', 'twp_agent_status', - 'twp_callbacks' + 'twp_callbacks', + 'twp_call_recordings' ); $missing_tables = array(); @@ -284,6 +285,30 @@ class TWP_Activator { KEY queue_id (queue_id) ) $charset_collate;"; + // Call recordings table + $table_recordings = $wpdb->prefix . 'twp_call_recordings'; + $sql_recordings = "CREATE TABLE $table_recordings ( + id int(11) NOT NULL AUTO_INCREMENT, + call_sid varchar(100) NOT NULL, + recording_sid varchar(100), + recording_url varchar(500), + duration int(11) DEFAULT 0, + from_number varchar(20), + to_number varchar(20), + agent_id bigint(20), + status varchar(20) DEFAULT 'recording', + started_at datetime DEFAULT CURRENT_TIMESTAMP, + ended_at datetime, + file_size int(11), + transcription text, + notes text, + PRIMARY KEY (id), + KEY call_sid (call_sid), + KEY recording_sid (recording_sid), + KEY agent_id (agent_id), + KEY started_at (started_at) + ) $charset_collate;"; + dbDelta($sql_schedules); dbDelta($sql_queues); dbDelta($sql_queued_calls); @@ -296,6 +321,7 @@ class TWP_Activator { dbDelta($sql_group_members); dbDelta($sql_agent_status); dbDelta($sql_callbacks); + dbDelta($sql_recordings); // Add missing columns for existing installations self::add_missing_columns(); diff --git a/includes/class-twp-core.php b/includes/class-twp-core.php index 8f626b2..3321321 100644 --- a/includes/class-twp-core.php +++ b/includes/class-twp-core.php @@ -201,6 +201,19 @@ class TWP_Core { $this->loader->add_action('wp_ajax_twp_get_conversation', $plugin_admin, 'ajax_get_conversation'); $this->loader->add_action('wp_ajax_twp_send_sms_reply', $plugin_admin, 'ajax_send_sms_reply'); + // Call control actions + $this->loader->add_action('wp_ajax_twp_toggle_hold', $plugin_admin, 'ajax_toggle_hold'); + $this->loader->add_action('wp_ajax_twp_transfer_call', $plugin_admin, 'ajax_transfer_call'); + $this->loader->add_action('wp_ajax_twp_requeue_call', $plugin_admin, 'ajax_requeue_call'); + $this->loader->add_action('wp_ajax_twp_start_recording', $plugin_admin, 'ajax_start_recording'); + $this->loader->add_action('wp_ajax_twp_stop_recording', $plugin_admin, 'ajax_stop_recording'); + $this->loader->add_action('wp_ajax_twp_get_call_recordings', $plugin_admin, 'ajax_get_call_recordings'); + $this->loader->add_action('wp_ajax_twp_delete_recording', $plugin_admin, 'ajax_delete_recording'); + $this->loader->add_action('wp_ajax_twp_get_online_agents', $plugin_admin, 'ajax_get_online_agents'); + $this->loader->add_action('wp_ajax_twp_transfer_to_agent_queue', $plugin_admin, 'ajax_transfer_to_agent_queue'); + $this->loader->add_action('wp_ajax_twp_check_personal_queue', $plugin_admin, 'ajax_check_personal_queue'); + $this->loader->add_action('wp_ajax_twp_accept_transfer_call', $plugin_admin, 'ajax_accept_transfer_call'); + // Frontend browser phone AJAX handlers are already covered by the admin handlers above // since they check permissions internally } diff --git a/includes/class-twp-shortcodes.php b/includes/class-twp-shortcodes.php index 8766e5d..f5f8b42 100644 --- a/includes/class-twp-shortcodes.php +++ b/includes/class-twp-shortcodes.php @@ -171,6 +171,24 @@ class TWP_Shortcodes { + + +

Your Queues

diff --git a/includes/class-twp-webhooks.php b/includes/class-twp-webhooks.php index 25694d3..31e50a0 100644 --- a/includes/class-twp-webhooks.php +++ b/includes/class-twp-webhooks.php @@ -100,6 +100,20 @@ class TWP_Webhooks { 'permission_callback' => '__return_true' )); + // Recording status webhook + register_rest_route('twilio-webhook/v1', '/recording-status', array( + 'methods' => 'POST', + 'callback' => array($this, 'handle_recording_status'), + 'permission_callback' => '__return_true' + )); + + // Resume call webhook (for unhold) + register_rest_route('twilio-webhook/v1', '/resume-call', array( + 'methods' => 'POST', + 'callback' => array($this, 'handle_resume_call'), + 'permission_callback' => '__return_true' + )); + // Smart routing webhook (checks user preference) register_rest_route('twilio-webhook/v1', '/smart-routing', array( 'methods' => 'POST', @@ -2251,4 +2265,78 @@ class TWP_Webhooks { // Optionally: Try to assign to another available agent // $this->try_assign_to_next_agent($queued_call->queue_id, $queued_call_id); } + + /** + * Handle recording status callback + */ + public function handle_recording_status($request) { + $params = $request->get_params(); + + error_log('TWP Recording Status: ' . print_r($params, true)); + + $recording_sid = isset($params['RecordingSid']) ? $params['RecordingSid'] : ''; + $recording_url = isset($params['RecordingUrl']) ? $params['RecordingUrl'] : ''; + $recording_status = isset($params['RecordingStatus']) ? $params['RecordingStatus'] : ''; + $recording_duration = isset($params['RecordingDuration']) ? intval($params['RecordingDuration']) : 0; + $call_sid = isset($params['CallSid']) ? $params['CallSid'] : ''; + + if ($recording_sid && $recording_status === 'completed') { + global $wpdb; + $recordings_table = $wpdb->prefix . 'twp_call_recordings'; + + // Update recording with URL and duration + $wpdb->update( + $recordings_table, + [ + 'recording_url' => $recording_url, + 'duration' => $recording_duration, + 'status' => 'completed', + 'ended_at' => current_time('mysql') + ], + ['recording_sid' => $recording_sid] + ); + + error_log("TWP: Recording completed - SID: $recording_sid, Duration: $recording_duration seconds"); + } + + // Return empty response + $response = new \Twilio\TwiML\VoiceResponse(); + return new WP_REST_Response($response->asXML(), 200, array('Content-Type' => 'text/xml')); + } + + /** + * Handle resume call (unhold) + */ + public function handle_resume_call($request) { + $params = $request->get_params(); + + error_log('TWP Resume Call: ' . print_r($params, true)); + + $call_sid = isset($params['CallSid']) ? $params['CallSid'] : ''; + + // Return empty TwiML to continue the call + $response = new \Twilio\TwiML\VoiceResponse(); + + // Check if this is a conference call + global $wpdb; + $call_log_table = $wpdb->prefix . 'twp_call_log'; + $call_info = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $call_log_table WHERE call_sid = %s", + $call_sid + )); + + if ($call_info && strpos($call_info->call_type, 'conference') !== false) { + // Rejoin conference + $dial = $response->dial(); + $dial->conference('Room_' . $call_sid, [ + 'startConferenceOnEnter' => true, + 'endConferenceOnExit' => true + ]); + } else { + // Just continue the call + $response->say('Call resumed'); + } + + return new WP_REST_Response($response->asXML(), 200, array('Content-Type' => 'text/xml')); + } } \ No newline at end of file