From 304b5de40ba5263683a09da7ea4392a1ffc17761 Mon Sep 17 00:00:00 2001 From: jknapp Date: Mon, 11 Aug 2025 20:31:48 -0700 Subject: [PATCH] progress made --- CLAUDE.md | 69 ++ admin/class-twp-admin.php | 679 ++++++++++++++++- assets/css/admin.css | 336 ++++++++- assets/js/admin.js | 957 ++++++++++++++++++++---- includes/class-twp-activator.php | 92 ++- includes/class-twp-agent-manager.php | 60 +- includes/class-twp-call-queue.php | 273 ++++++- includes/class-twp-callback-manager.php | 6 +- includes/class-twp-core.php | 99 +++ includes/class-twp-elevenlabs-api.php | 6 + includes/class-twp-scheduler.php | 103 ++- includes/class-twp-twilio-api.php | 187 ++++- includes/class-twp-webhooks.php | 836 ++++++++++++++++++--- includes/class-twp-workflow.php | 710 +++++++++++++++--- twilio-wp-plugin.php | 19 +- 15 files changed, 4028 insertions(+), 404 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d64b30d..2131355 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,75 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## ๐Ÿšจ CRITICAL: Testing & Deployment Environment + +**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/` +- **Development Path**: `/home/jknapp/code/twilio-wp-plugin/` +- **Deployment Method**: Files synced via rsync from development to Docker container + +**IMPORTANT**: +- NEVER assume local testing - all tests must work on remote server +- Direct PHP tests work (`php test-twilio-direct.php send`) +- WordPress admin context has issues that need investigation + +## ๐Ÿ“ž Standardized Phone Number Variable Names + +**THESE NAMING CONVENTIONS MUST BE STRICTLY FOLLOWED:** + +### Required Variable Names: +- **`incoming_number`** = Phone number that initiated contact (sent SMS/call TO the system) +- **`agent_number`** = Phone number used to reach a specific agent +- **`customer_number`** = Phone number of customer calling into the system +- **`workflow_number`** = Twilio number assigned to a specific workflow +- **`queue_number`** = Twilio number assigned to a specific queue +- **`default_number`** = Default Twilio number when none specified + +### BANNED Variable Names (DO NOT USE): +- โŒ `from_number` - Ambiguous, could be customer or system +- โŒ `to_number` - Ambiguous, could be agent or system +- โŒ `phone_number` - Too generic, must specify whose number +- โŒ `$agent_phone` - Use `$agent_number` instead + +### Test Numbers: +- **Twilio Number**: `+19516215107` +- **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: diff --git a/admin/class-twp-admin.php b/admin/class-twp-admin.php index 82b90a1..25f6967 100644 --- a/admin/class-twp-admin.php +++ b/admin/class-twp-admin.php @@ -370,12 +370,247 @@ class TWP_Admin {

Phone number to receive SMS notifications for urgent voicemails. Use full international format (e.g., +1234567890)

+ + + Default SMS From Number + + + +

Default Twilio phone number to use as sender for SMS messages when not in a workflow context.

+ + +
+ +

Phone Number Maintenance

+
+

Real-Time Queue Cleanup Configuration

+

Configure individual phone numbers to send status callbacks when calls end, enabling real-time queue cleanup.

+

When enabled: Calls will be removed from queue immediately when callers hang up.

+ +
+

Loading phone numbers...

+
+ +
+ + +
+ +
+
+ '; break; case 'sms': @@ -378,23 +556,183 @@ jQuery(document).ready(function($) { var html = '
'; html += ''; html += ''; - html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; - html += ''; + + // Target input - varies based on action type + html += '
'; + + // Forward action - phone number input + html += ''; + + // Queue action - queue dropdown + html += ''; + + // Voicemail action - message input + html += ''; + + // Message action - message input + html += ''; + + html += '
'; + html += ''; html += '
'; return html; } + function generateAfterHoursStepHtml(step, index) { + var html = '
'; + html += '
'; + html += '' + (index + 1) + '.'; + html += '' + getStepTypeName(step.type) + ''; + html += ''; + html += '
'; + html += '
'; + + switch(step.type) { + case 'greeting': + html += ''; + break; + case 'forward': + html += ''; + break; + case 'voicemail': + html += ''; + break; + case 'queue': + html += ''; + break; + case 'sms': + html += ''; + html += ''; + break; + } + + html += ''; + html += '
'; + html += '
'; + return html; + } + + window.addAfterHoursStep = function() { + var stepType = $('#after-hours-step-type').val(); + if (!stepType) { + alert('Please select a step type'); + return; + } + + // Remove the "no steps" message if it exists + $('.no-steps-message').remove(); + + var stepList = $('.after-hours-step-list'); + var currentSteps = stepList.find('.after-hours-step').length; + + var newStep = { + type: stepType, + message: '', + number: '', + greeting: '', + queue_name: '', + to_number: '' + }; + + stepList.append(generateAfterHoursStepHtml(newStep, currentSteps)); + $('#after-hours-step-type').val(''); + }; + + window.removeAfterHoursStep = function(index) { + $('.after-hours-step[data-index="' + index + '"]').remove(); + + // Reindex remaining steps + $('.after-hours-step').each(function(i) { + $(this).attr('data-index', i); + $(this).find('.step-number').text((i + 1) + '.'); + $(this).find('input, textarea').each(function() { + var name = $(this).attr('name'); + if (name) { + name = name.replace(/\[\d+\]/, '[' + i + ']'); + $(this).attr('name', name); + } + }); + $(this).find('.remove-step').attr('onclick', 'removeAfterHoursStep(' + i + ')'); + }); + + if ($('.after-hours-step').length === 0) { + $('.after-hours-step-list').html('

No after-hours steps configured. Add steps below.

'); + } + }; + + window.loadSchedulesForStep = function() { + console.log('Loading schedules for step modal'); + $.post(twp_ajax.ajax_url, { + action: 'twp_get_schedules', + nonce: twp_ajax.nonce + }, function(response) { + console.log('Schedule response:', response); + if (response.success) { + var $select = $('#schedule-select'); + var currentScheduleId = $select.data('current') || $select.val(); + console.log('Current schedule ID to select:', currentScheduleId); + console.log('Available schedules:', response.data.length); + console.log('Select element found:', $select.length > 0); + console.log('Select element name attribute:', $select.attr('name')); + + var options = ''; + + response.data.forEach(function(schedule) { + var selected = (schedule.id == currentScheduleId) ? ' selected' : ''; + options += ''; + console.log('Added schedule option:', schedule.schedule_name, 'ID:', schedule.id, 'Selected:', selected); + }); + + $select.html(options); + + // Ensure the current value is selected after DOM update + setTimeout(function() { + if (currentScheduleId) { + $select.val(currentScheduleId); + console.log('Set schedule select value to:', currentScheduleId, 'Current value:', $select.val()); + + // Force trigger change event to ensure any listeners are notified + $select.trigger('change'); + } + }, 10); + + } else { + console.error('Failed to load schedules:', response); + $('#schedule-select').html(''); + } + }).fail(function(xhr, status, error) { + console.error('AJAX error loading schedules:', error); + $('#schedule-select').html(''); + }); + }; + window.addIvrOption = function() { var nextDigit = $('#ivr-options-list .ivr-option').length + 1; var newOptionHtml = generateIvrOptionHtml(nextDigit.toString(), { action: 'forward', number: '' }); $('#ivr-options-list').append(newOptionHtml); }; + window.removeIvrOption = function(button) { + $(button).closest('.ivr-option').remove(); + }; + // Save step configuration $('#save-step-btn').on('click', function() { var stepId = parseInt($('#step-id').val()); @@ -407,6 +745,11 @@ jQuery(document).ready(function($) { // Parse form data into step data step.data = parseStepFormData(stepType, formData); + console.log('Saved step data:', step); + if (stepType === 'schedule_check') { + console.log('Saved schedule step data:', step.data); + } + updateWorkflowDisplay(); closeStepConfigModal(); }); @@ -414,9 +757,18 @@ jQuery(document).ready(function($) { function parseStepFormData(stepType, formData) { var data = {}; + console.log('Parsing form data for step type:', stepType); + console.log('Raw form data:', formData); + formData.forEach(function(field) { + console.log('Processing field:', field.name, '=', field.value); + if (field.name === 'use_tts') { data.use_tts = true; + } else if (field.name === 'audio_type') { + data.audio_type = field.value; + } else if (field.name === 'audio_url') { + data.audio_url = field.value; } else if (field.name === 'voice_id') { data.voice_id = field.value; } else if (field.name.endsWith('[]')) { @@ -428,6 +780,24 @@ jQuery(document).ready(function($) { } }); + console.log('Parsed data object:', data); + + // For queue steps, also save the queue name for display purposes + if (stepType === 'queue' && data.queue_id) { + var selectedOption = $('#step-config-form select[name="queue_id"] option:selected'); + if (selectedOption.length) { + data.queue_name = selectedOption.text(); + } + } + + // For schedule steps, also save the schedule name for display purposes + if (stepType === 'schedule_check' && data.schedule_id) { + var selectedOption = $('#step-config-form select[name="schedule_id"] option:selected'); + if (selectedOption.length) { + data.schedule_name = selectedOption.text(); + } + } + // Handle IVR options specially if (stepType === 'ivr_menu' && data.digit) { data.options = {}; @@ -446,6 +816,47 @@ jQuery(document).ready(function($) { delete data.target; } + // Handle schedule check after-hours steps + if (stepType === 'schedule_check') { + console.log('Parsing schedule_check step data:', data); + + var afterHoursSteps = []; + + // First, remove any after_hours_steps fields from the main data object + var fieldsToRemove = []; + for (var key in data) { + if (key.startsWith('after_hours_steps[')) { + fieldsToRemove.push(key); + } + } + fieldsToRemove.forEach(function(key) { + delete data[key]; + }); + + // Find all after_hours_steps fields + formData.forEach(function(field) { + var match = field.name.match(/after_hours_steps\[(\d+)\]\[(\w+)\]/); + if (match) { + var index = parseInt(match[1]); + var key = match[2]; + + if (!afterHoursSteps[index]) { + afterHoursSteps[index] = {}; + } + afterHoursSteps[index][key] = field.value; + } + }); + + // Filter out empty entries and reassign + data.after_hours_steps = afterHoursSteps.filter(function(step) { + return step && step.type; + }); + + console.log('Parsed schedule_check data:', data); + console.log('After hours steps count:', data.after_hours_steps.length); + console.log('After hours steps:', data.after_hours_steps); + } + return data; } @@ -516,11 +927,24 @@ jQuery(document).ready(function($) { case 'forward': return step.data.forward_number ? 'Forward to ' + step.data.forward_number : 'No number set'; case 'queue': - return step.data.queue_name ? 'Add to queue: ' + step.data.queue_name : 'No queue selected'; + // Display the queue name if available, otherwise show ID + if (step.data.queue_name) { + return 'Add to queue: ' + step.data.queue_name; + } else if (step.data.queue_id) { + return 'Add to queue (ID: ' + step.data.queue_id + ')'; + } else { + return 'No queue selected'; + } case 'voicemail': return 'Record voicemail (max ' + (step.data.max_length || 120) + 's)'; case 'schedule_check': - return 'Route based on business hours'; + var scheduleInfo = 'No schedule selected'; + if (step.data.schedule_id) { + var scheduleName = step.data.schedule_name || ('Schedule ID: ' + step.data.schedule_id); + scheduleInfo = 'Schedule: ' + scheduleName; + } + var afterHoursCount = step.data.after_hours_steps ? step.data.after_hours_steps.length : 0; + return scheduleInfo + (afterHoursCount > 0 ? ' (' + afterHoursCount + ' after-hours steps)' : ''); case 'sms': return step.data.to_number ? 'Send SMS to ' + step.data.to_number : 'No recipient set'; default: @@ -593,30 +1017,48 @@ jQuery(document).ready(function($) { if (response.success) { var workflow = response.data; - $('#workflow-builder').show(); $('#workflow-modal-title').text('Edit Workflow: ' + workflow.workflow_name); + tb_show('Edit Workflow: ' + workflow.workflow_name, '#TB_inline?inlineId=workflow-builder&width=900&height=700'); // Set basic info $('#workflow-basic-info')[0].reset(); $('[name="workflow_name"]').val(workflow.workflow_name); - $('[name="phone_number"]').val(workflow.phone_number); $('[name="is_active"]').prop('checked', workflow.is_active == '1'); + // Store the phone number to select after options are loaded + var phoneNumberToSelect = workflow.phone_number; + // Load workflow data currentWorkflowId = workflowId; + console.log('Loading workflow data:', workflow.workflow_data); if (workflow.workflow_data) { try { var workflowData = JSON.parse(workflow.workflow_data); workflowSteps = workflowData.steps || []; + console.log('Parsed workflow steps:', workflowSteps); + + // Debug schedule steps specifically + workflowSteps.forEach(function(step, index) { + if (step.type === 'schedule_check') { + console.log('Found schedule step at index', index, ':', step); + } + }); } catch (e) { console.error('Error parsing workflow data:', e); + console.log('Raw workflow data that failed to parse:', workflow.workflow_data); workflowSteps = []; } } else { + console.log('No workflow data found'); workflowSteps = []; } - loadPhoneNumbers(); + // Load phone numbers and select the saved one + loadPhoneNumbers(function() { + $('#workflow-phone').val(phoneNumberToSelect); + // Trigger change event in case there are any listeners + $('#workflow-phone').trigger('change'); + }); updateWorkflowDisplay(); } else { alert('Error loading workflow: ' + (response.data || 'Unknown error')); @@ -659,13 +1101,13 @@ jQuery(document).ready(function($) { // Queue Management window.openQueueModal = function() { - $('#queue-modal').show(); $('#queue-form')[0].reset(); $('#queue-id').val(''); + tb_show('Create New Queue', '#TB_inline?inlineId=queue-modal&width=600&height=550'); }; window.closeQueueModal = function() { - $('#queue-modal').hide(); + tb_remove(); }; window.editQueue = function(queueId) { @@ -679,11 +1121,13 @@ jQuery(document).ready(function($) { var queue = response.data; $('#queue-id').val(queue.id); $('[name="queue_name"]').val(queue.queue_name); + $('[name="phone_number"]').val(queue.phone_number); + $('[name="agent_group_id"]').val(queue.agent_group_id); $('[name="max_size"]').val(queue.max_size); $('[name="timeout_seconds"]').val(queue.timeout_seconds); $('[name="wait_music_url"]').val(queue.wait_music_url); $('[name="tts_message"]').val(queue.tts_message); - $('#queue-modal').show(); + tb_show('Edit Queue: ' + queue.queue_name, '#TB_inline?inlineId=queue-modal&width=600&height=550'); } }); }; @@ -743,24 +1187,32 @@ jQuery(document).ready(function($) { // Remove existing modal if present $('#queue-details-modal').remove(); - // Create modal HTML - var modalHtml = '
'; - modalHtml += '
'; - modalHtml += '
'; - modalHtml += ''; - modalHtml += '
'; - modalHtml += '
' + content + '
'; - modalHtml += ''; - modalHtml += '
'; - modalHtml += '
'; + // Create WordPress-style modal HTML + var modalHtml = ` + + `; - // Add modal to page + // Add modal HTML to body $('body').append(modalHtml); + + // Use WordPress ThickBox + tb_show('Queue Details', '#TB_inline?inlineId=queue-details-modal&width=600&height=400'); } window.closeQueueDetailsModal = function() { + tb_remove(); $('#queue-details-modal').remove(); }; @@ -962,11 +1414,11 @@ jQuery(document).ready(function($) { window.configureNumber = function(numberSid, phoneNumber) { $('#number-sid').val(numberSid); $('#phone-number').val(phoneNumber); - $('#number-config-modal').show(); + tb_show('Configure: ' + phoneNumber, '#TB_inline?inlineId=number-config-modal&width=600&height=400'); }; window.closeNumberConfigModal = function() { - $('#number-config-modal').hide(); + tb_remove(); }; $('#number-config-form').on('submit', function(e) { @@ -1026,8 +1478,8 @@ jQuery(document).ready(function($) { var options = ''; response.data.forEach(function(queue) { - var selected = queue.queue_name === currentValue ? ' selected' : ''; - options += ''; + var selected = queue.id == currentValue ? ' selected' : ''; + options += ''; }); $select.html(options); @@ -1040,6 +1492,27 @@ jQuery(document).ready(function($) { }); }; + // Load queues for a specific select element (used in IVR options) + function loadQueuesForSelect($select) { + var currentValue = $select.data('current') || $select.val(); + + $.post(twp_ajax.ajax_url, { + action: 'twp_get_all_queues', + nonce: twp_ajax.nonce + }, function(response) { + if (response.success) { + var options = ''; + + response.data.forEach(function(queue) { + var selected = queue.id == currentValue ? ' selected' : ''; + options += ''; + }); + + $select.html(options); + } + }); + } + // Voice management for workflow steps window.loadWorkflowVoices = function(button) { var $button = $(button); @@ -1084,7 +1557,27 @@ jQuery(document).ready(function($) { }); }; - // Toggle TTS options visibility + // Toggle audio type options visibility + $(document).on('change', 'input[name="audio_type"]', function() { + var $container = $(this).closest('.step-config-section'); + var $ttsOptions = $container.find('.tts-options'); + var $audioOptions = $container.find('.audio-options'); + var audioType = $(this).val(); + + // Hide all options first + $ttsOptions.hide(); + $audioOptions.hide(); + + // Show the appropriate options based on selection + if (audioType === 'tts') { + $ttsOptions.show(); + } else if (audioType === 'audio') { + $audioOptions.show(); + } + // 'say' type doesn't need additional options + }); + + // Legacy support for old use_tts checkbox (in case old workflows exist) $(document).on('change', 'input[name="use_tts"]', function() { var $ttsOptions = $(this).closest('.step-config-section').find('.tts-options'); if ($(this).is(':checked')) { @@ -1094,6 +1587,37 @@ jQuery(document).ready(function($) { } }); + // Handle IVR action changes to show/hide appropriate target inputs + $(document).on('change', '.ivr-action-select', function() { + var $option = $(this).closest('.ivr-option'); + var $container = $option.find('.ivr-target-container'); + var action = $(this).val(); + + // Hide all target inputs + $container.find('.target-forward, .target-queue, .target-voicemail, .target-message').hide(); + + // Show the appropriate input based on action + switch(action) { + case 'forward': + $container.find('.target-forward').show(); + break; + case 'queue': + $container.find('.target-queue').show(); + // Load queues if not already loaded + var $queueSelect = $container.find('.target-queue'); + if ($queueSelect.find('option').length <= 1) { + loadQueuesForSelect($queueSelect); + } + break; + case 'voicemail': + $container.find('.target-voicemail').show(); + break; + case 'message': + $container.find('.target-message').show(); + break; + } + }); + // Auto-load voices when step config modal opens if API key exists $(document).on('click', '.step-btn', function() { setTimeout(function() { @@ -1113,18 +1637,7 @@ jQuery(document).ready(function($) { // Voicemail Management window.playVoicemail = function(voicemailId, recordingUrl) { - var audio = document.getElementById('voicemail-audio'); - if (audio) { - audio.src = recordingUrl; - audio.play(); - - // Show voicemail modal and load transcription - showVoicemail(voicemailId, recordingUrl); - } - }; - - window.viewVoicemail = function(voicemailId) { - // Load voicemail details via AJAX + // First load voicemail details $.post(twp_ajax.ajax_url, { action: 'twp_get_voicemail', voicemail_id: voicemailId, @@ -1132,19 +1645,66 @@ jQuery(document).ready(function($) { }, function(response) { if (response.success) { var voicemail = response.data; - showVoicemail(voicemail.id, voicemail.recording_url, voicemail.transcription); + + // Update modal with voicemail details + $('#voicemail-from').text(voicemail.from_number || 'Unknown'); + $('#voicemail-date').text(voicemail.created_at || ''); + $('#voicemail-duration').text(voicemail.duration ? voicemail.duration + ' seconds' : 'Unknown'); + $('#voicemail-player-modal').data('voicemail-id', voicemailId); + + // Show modal using ThickBox + tb_show('Voicemail Player', '#TB_inline?inlineId=voicemail-player-modal&width=600&height=500'); + + // Now fetch the audio data + $.post(twp_ajax.ajax_url, { + action: 'twp_get_voicemail_audio', + voicemail_id: voicemailId, + nonce: twp_ajax.nonce + }, function(audioResponse) { + if (audioResponse.success) { + var audio = document.getElementById('voicemail-audio'); + if (audio) { + // Use the data URL directly + audio.src = audioResponse.data.audio_url; + + // Show transcription + showVoicemail(voicemailId, audioResponse.data.audio_url, voicemail.transcription); + + // Play after loading + audio.play().catch(function(error) { + console.log('Audio play error:', error); + // If data URL fails, try direct Twilio URL as fallback + if (voicemail.recording_url) { + audio.src = voicemail.recording_url + '.mp3'; + audio.play().catch(function(e) { + alert('Unable to play audio. The recording may require authentication.'); + }); + } + }); + } + } else { + alert('Error loading audio: ' + (audioResponse.data || 'Unknown error')); + console.error('Audio load error:', audioResponse); + } + }).fail(function(xhr, status, error) { + alert('Failed to load audio: ' + error); + console.error('AJAX error:', xhr.responseText); + }); } else { - alert('Error loading voicemail details'); + alert('Error loading voicemail: ' + (response.data || 'Unknown error')); } + }).fail(function() { + alert('Failed to load voicemail details'); }); }; + window.viewVoicemail = function(voicemailId) { + // Just use playVoicemail without auto-play + playVoicemail(voicemailId, ''); + }; + function showVoicemail(voicemailId, recordingUrl, transcription) { - // Set the audio source - var audio = document.getElementById('voicemail-audio'); - if (audio && recordingUrl) { - audio.src = recordingUrl; - } + // Set the audio source - already set by playVoicemail // Set transcription text var transcriptionDiv = document.getElementById('voicemail-transcription-text'); @@ -1160,12 +1720,13 @@ jQuery(document).ready(function($) { } else if (transcription === 'Transcription failed') { transcriptionDiv.innerHTML = '

Transcription failed. Please try again.

'; } else { - transcriptionDiv.innerHTML = 'Transcription pending... This will be updated automatically when ready.'; + transcriptionDiv.innerHTML = 'No transcription available.'; // Show the generate transcription button var transcribeBtn = document.getElementById('transcribe-btn'); if (transcribeBtn) { transcribeBtn.style.display = 'inline-block'; + transcribeBtn.setAttribute('data-voicemail-id', voicemailId); } } } @@ -1174,18 +1735,11 @@ jQuery(document).ready(function($) { window.currentVoicemailId = voicemailId; window.currentRecordingUrl = recordingUrl; - // Show modal - var modal = document.getElementById('voicemail-modal'); - if (modal) { - modal.style.display = 'flex'; - } + // Modal is already shown by playVoicemail function } window.closeVoicemailModal = function() { - var modal = document.getElementById('voicemail-modal'); - if (modal) { - modal.style.display = 'none'; - } + tb_remove(); // Stop audio playback var audio = document.getElementById('voicemail-audio'); @@ -1235,56 +1789,52 @@ jQuery(document).ready(function($) { }; window.transcribeVoicemail = function() { - if (window.currentVoicemailId) { - var transcribeBtn = document.getElementById('transcribe-btn'); - if (transcribeBtn) { - transcribeBtn.innerHTML = 'Generating...'; - transcribeBtn.disabled = true; - } - - $.post(twp_ajax.ajax_url, { - action: 'twp_transcribe_voicemail', - voicemail_id: window.currentVoicemailId, - nonce: twp_ajax.nonce - }, function(response) { - if (response.success) { - var transcriptionDiv = document.getElementById('voicemail-transcription-text'); - if (transcriptionDiv) { - transcriptionDiv.innerHTML = '

' + response.data.transcription + '

'; - } - - if (transcribeBtn) { - transcribeBtn.style.display = 'none'; - } - } else { - alert('Error generating transcription: ' + response.data); - - if (transcribeBtn) { - transcribeBtn.innerHTML = 'Generate Transcription'; - transcribeBtn.disabled = false; - } - } - }); + var voicemailId = $('#transcribe-btn').data('voicemail-id') || window.currentVoicemailId; + + if (!voicemailId) { + alert('No voicemail selected'); + return; } + + $('#transcribe-btn').text('Generating...').prop('disabled', true); + + $.post(twp_ajax.ajax_url, { + action: 'twp_transcribe_voicemail', + voicemail_id: voicemailId, + nonce: twp_ajax.nonce + }, function(response) { + if (response.success) { + $('#voicemail-transcription-text').html('

' + response.data.transcription + '

'); + $('#transcribe-btn').hide(); + } else { + alert('Error generating transcription'); + $('#transcribe-btn').text('Generate Transcription').prop('disabled', false); + } + }); }; - // Close modal when clicking outside - $(document).on('click', '#voicemail-modal', function(e) { - if (e.target.id === 'voicemail-modal') { - closeVoicemailModal(); - } - }); + window.filterVoicemails = function() { + // TODO: Implement filtering + alert('Filtering coming soon'); + }; + + window.exportVoicemails = function() { + // TODO: Implement export + alert('Export coming soon'); + }; + + // Removed modal click handler - ThickBox handles closing // Agent Group Management Functions window.openGroupModal = function() { - $('#group-modal').show(); $('#group-form')[0].reset(); $('#group-id').val(''); $('#group-modal-title').text('Add New Group'); + tb_show('Add New Group', '#TB_inline?inlineId=group-modal&width=600&height=500'); }; window.closeGroupModal = function() { - $('#group-modal').hide(); + tb_remove(); }; window.saveGroup = function() { @@ -1315,7 +1865,7 @@ jQuery(document).ready(function($) { $('[name="description"]').val(group.description); $('[name="ring_strategy"]').val(group.ring_strategy); $('[name="timeout_seconds"]').val(group.timeout_seconds); - $('#group-modal').show(); + tb_show('Edit Group: ' + group.group_name, '#TB_inline?inlineId=group-modal&width=600&height=500'); } else { alert('Error loading group: ' + (response.data || 'Unknown error')); } @@ -1341,11 +1891,11 @@ jQuery(document).ready(function($) { window.manageGroupMembers = function(groupId) { $('#current-group-id').val(groupId); loadGroupMembers(groupId); - $('#members-modal').show(); + tb_show('Manage Group Members', '#TB_inline?inlineId=members-modal&width=700&height=600'); }; window.closeMembersModal = function() { - $('#members-modal').hide(); + tb_remove(); }; function loadGroupMembers(groupId) { @@ -1444,18 +1994,165 @@ jQuery(document).ready(function($) { nonce: twp_ajax.nonce }, function(response) { if (response.success) { - alert('Call accepted! You should receive the call shortly.'); + // Show success modal instead of basic alert + showCallAcceptedModal(); refreshWaitingCalls(); } else { - alert('Error accepting call: ' + response.data); + // Show error modal + showErrorModal('Error accepting call: ' + response.data); button.prop('disabled', false).text('Accept'); } }).fail(function() { - alert('Failed to accept call. Please try again.'); + showErrorModal('Failed to accept call. Please try again.'); button.prop('disabled', false).text('Accept'); }); }; + // Modal functions using WordPress built-in modal system + window.showCallAcceptedModal = function() { + // Create WordPress-style modal HTML + var modalHtml = ` + + `; + + // Add modal HTML to body if not already there + if (!document.getElementById('twp-call-accepted-modal')) { + $('body').append(modalHtml); + } + + // Use WordPress ThickBox + tb_show('Call Accepted', '#TB_inline?inlineId=twp-call-accepted-modal&width=400&height=250'); + + // Auto close after 5 seconds + setTimeout(function() { + tb_remove(); + }, 5000); + }; + + window.showErrorModal = function(message) { + // Create WordPress-style error modal HTML + var modalHtml = ` + + `; + + // Remove existing error modal + $('#twp-error-modal').remove(); + + // Add modal HTML to body + $('body').append(modalHtml); + + // Use WordPress ThickBox + tb_show('Error', '#TB_inline?inlineId=twp-error-modal&width=400&height=200'); + }; + + // View call details function for Call Log page + window.viewCallDetails = function(callSid) { + // Fetch call details via AJAX + $.post(twp_ajax.ajax_url, { + action: 'twp_get_call_details', + call_sid: callSid, + nonce: twp_ajax.nonce + }, function(response) { + if (response.success) { + var call = response.data; + + // Build detailed view HTML + var detailsHtml = ` +
+

Call Information

+ + + + + + + + + +
Call SID:${call.call_sid || 'N/A'}
From:${call.from_number || 'N/A'}
To:${call.to_number || 'N/A'}
Status:${call.status || 'N/A'}
Duration:${call.duration ? call.duration + ' seconds' : 'N/A'}
Workflow:${call.workflow_name || 'N/A'}
Queue Time:${call.queue_time ? call.queue_time + ' seconds' : 'N/A'}
Created:${call.created_at || 'N/A'}
+ + ${call.actions_taken ? ` +

Actions Taken

+
+
${call.actions_taken}
+
+ ` : ''} + + ${call.recording_url ? ` +

Recording

+ + ` : ''} +
+ `; + + // Show in WordPress modal + showCallDetailsModal(detailsHtml); + } else { + showErrorModal('Failed to load call details: ' + (response.data || 'Unknown error')); + } + }).fail(function() { + showErrorModal('Failed to load call details. Please try again.'); + }); + }; + + // Show call details modal + function showCallDetailsModal(content) { + // Remove existing modal if present + $('#call-details-modal').remove(); + + // Create WordPress-style modal HTML + var modalHtml = ` + + `; + + // Add modal HTML to body + $('body').append(modalHtml); + + // Use WordPress ThickBox + tb_show('Call Details', '#TB_inline?inlineId=call-details-modal&width=700&height=500'); + } + window.refreshWaitingCalls = function() { if (!$('#waiting-calls-list').length) return; diff --git a/includes/class-twp-activator.php b/includes/class-twp-activator.php index 87e6721..74f89c2 100644 --- a/includes/class-twp-activator.php +++ b/includes/class-twp-activator.php @@ -14,6 +14,11 @@ class TWP_Activator { // Set default options self::set_default_options(); + // Set the database version + if (defined('TWP_DB_VERSION')) { + update_option('twp_db_version', TWP_DB_VERSION); + } + // Create webhook endpoints flush_rewrite_rules(); } @@ -55,6 +60,9 @@ class TWP_Activator { return false; // Tables were missing } + // Check for and perform any needed migrations + self::migrate_tables(); + return true; // All tables exist } @@ -72,7 +80,7 @@ class TWP_Activator { id int(11) NOT NULL AUTO_INCREMENT, phone_number varchar(20), schedule_name varchar(100) NOT NULL, - days_of_week varchar(20) NOT NULL, + days_of_week varchar(100) NOT NULL, start_time time NOT NULL, end_time time NOT NULL, workflow_id varchar(100), @@ -80,6 +88,7 @@ class TWP_Activator { after_hours_action varchar(20) DEFAULT 'workflow', after_hours_workflow_id varchar(100), after_hours_forward_number varchar(20), + holiday_dates text, is_active tinyint(1) DEFAULT 1, created_at datetime DEFAULT CURRENT_TIMESTAMP, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -92,12 +101,16 @@ class TWP_Activator { $sql_queues = "CREATE TABLE $table_queues ( id int(11) NOT NULL AUTO_INCREMENT, queue_name varchar(100) NOT NULL, + phone_number varchar(20), + agent_group_id int(11), max_size int(11) DEFAULT 10, wait_music_url varchar(255), tts_message text, timeout_seconds int(11) DEFAULT 300, created_at datetime DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id) + PRIMARY KEY (id), + KEY agent_group_id (agent_group_id), + KEY phone_number (phone_number) ) $charset_collate;"; // Queued calls table @@ -110,12 +123,15 @@ class TWP_Activator { to_number varchar(20) NOT NULL, position int(11) NOT NULL, status varchar(20) DEFAULT 'waiting', + agent_phone varchar(20), + agent_call_sid varchar(100), joined_at datetime DEFAULT CURRENT_TIMESTAMP, answered_at datetime, ended_at datetime, PRIMARY KEY (id), KEY queue_id (queue_id), - KEY call_sid (call_sid) + KEY call_sid (call_sid), + KEY status (status) ) $charset_collate;"; // Workflows table @@ -262,6 +278,75 @@ class TWP_Activator { dbDelta($sql_group_members); dbDelta($sql_agent_status); dbDelta($sql_callbacks); + + // Add missing columns for existing installations + self::add_missing_columns(); + } + + /** + * Add missing columns for existing installations + */ + private static function add_missing_columns() { + global $wpdb; + + $table_schedules = $wpdb->prefix . 'twp_phone_schedules'; + + // Check if holiday_dates column exists + $column_exists = $wpdb->get_results("SHOW COLUMNS FROM $table_schedules LIKE 'holiday_dates'"); + + if (empty($column_exists)) { + $wpdb->query("ALTER TABLE $table_schedules ADD COLUMN holiday_dates text AFTER after_hours_forward_number"); + } + + // Check if days_of_week column needs to be expanded + $column_info = $wpdb->get_results("SHOW COLUMNS FROM $table_schedules LIKE 'days_of_week'"); + if (!empty($column_info) && $column_info[0]->Type === 'varchar(20)') { + $wpdb->query("ALTER TABLE $table_schedules MODIFY COLUMN days_of_week varchar(100) NOT NULL"); + } + + // Add new columns to call queues table + $table_queues = $wpdb->prefix . 'twp_call_queues'; + + // Check if phone_number column exists in queues table + $phone_column_exists = $wpdb->get_results("SHOW COLUMNS FROM $table_queues LIKE 'phone_number'"); + if (empty($phone_column_exists)) { + $wpdb->query("ALTER TABLE $table_queues ADD COLUMN phone_number varchar(20) AFTER queue_name"); + $wpdb->query("ALTER TABLE $table_queues ADD INDEX phone_number (phone_number)"); + } + + // Check if agent_group_id column exists in queues table + $group_column_exists = $wpdb->get_results("SHOW COLUMNS FROM $table_queues LIKE 'agent_group_id'"); + if (empty($group_column_exists)) { + $wpdb->query("ALTER TABLE $table_queues ADD COLUMN agent_group_id int(11) AFTER phone_number"); + $wpdb->query("ALTER TABLE $table_queues ADD INDEX agent_group_id (agent_group_id)"); + } + + // Add agent columns to queued_calls table if they don't exist + $table_queued_calls = $wpdb->prefix . 'twp_queued_calls'; + + $agent_phone_exists = $wpdb->get_results("SHOW COLUMNS FROM $table_queued_calls LIKE 'agent_phone'"); + if (empty($agent_phone_exists)) { + $wpdb->query("ALTER TABLE $table_queued_calls ADD COLUMN agent_phone varchar(20) AFTER status"); + } + + $agent_call_sid_exists = $wpdb->get_results("SHOW COLUMNS FROM $table_queued_calls LIKE 'agent_call_sid'"); + if (empty($agent_call_sid_exists)) { + $wpdb->query("ALTER TABLE $table_queued_calls ADD COLUMN agent_call_sid varchar(100) AFTER agent_phone"); + } + + // Add status index if it doesn't exist + $status_index_exists = $wpdb->get_results("SHOW INDEX FROM $table_queued_calls WHERE Key_name = 'status'"); + if (empty($status_index_exists)) { + $wpdb->query("ALTER TABLE $table_queued_calls ADD INDEX status (status)"); + } + } + + /** + * Perform table migrations for existing installations + */ + private static function migrate_tables() { + // Call the existing add_missing_columns function which now includes queue columns + self::add_missing_columns(); } /** @@ -277,5 +362,6 @@ class TWP_Activator { add_option('twp_default_queue_size', 10); add_option('twp_urgent_keywords', 'urgent,emergency,important,asap,help'); add_option('twp_sms_notification_number', ''); + add_option('twp_default_sms_number', ''); } } \ No newline at end of file diff --git a/includes/class-twp-agent-manager.php b/includes/class-twp-agent-manager.php index c78e4cd..9599004 100644 --- a/includes/class-twp-agent-manager.php +++ b/includes/class-twp-agent-manager.php @@ -234,21 +234,53 @@ class TWP_Agent_Manager { // Set agent status to busy self::set_agent_status($user_id, 'busy', $call->call_sid); - // Forward the call to the agent + // Make a new call to the agent with proper caller ID $twilio = new TWP_Twilio_API(); - // Create TwiML to redirect the call - $twiml = new \Twilio\TwiML\VoiceResponse(); - $twiml->dial($phone_number, [ - 'statusCallback' => home_url('/wp-json/twilio-webhook/v1/call-status'), - 'statusCallbackEvent' => array('completed') - ]); - - // Update the call with new TwiML - $result = $twilio->update_call($call->call_sid, array( - 'Twiml' => $twiml->asXML() + // Get the queue's phone number for proper caller ID (same logic as SMS webhook) + $queues_table = $wpdb->prefix . 'twp_call_queues'; + $queue_info = $wpdb->get_row($wpdb->prepare( + "SELECT phone_number FROM $queues_table WHERE id = %d", + $call->queue_id )); + // Priority: 1) Queue's phone number, 2) Call's original to_number, 3) Default SMS number + $workflow_number = null; + if (!empty($queue_info->phone_number)) { + $workflow_number = $queue_info->phone_number; + error_log('TWP Web Accept: Using queue phone number: ' . $workflow_number); + } elseif (!empty($call->to_number)) { + $workflow_number = $call->to_number; + error_log('TWP Web Accept: Using original workflow number: ' . $workflow_number); + } else { + $workflow_number = TWP_Twilio_API::get_sms_from_number(); + error_log('TWP Web Accept: Using default number: ' . $workflow_number); + } + + // Create webhook URL for screening the agent call + $connect_url = home_url('/wp-json/twilio-webhook/v1/agent-screen'); + $connect_url = add_query_arg(array( + 'queued_call_id' => $call_id, + 'customer_number' => $call->from_number, + 'customer_call_sid' => $call->call_sid + ), $connect_url); + + // Create status callback URL to detect voicemail/no-answer + $status_callback_url = home_url('/wp-json/twilio-webhook/v1/agent-call-status'); + $status_callback_url = add_query_arg(array( + 'queued_call_id' => $call_id, + 'user_id' => $user_id, + 'original_call_sid' => $call->call_sid + ), $status_callback_url); + + // Make call to agent with proper workflow number as caller ID and status tracking + $result = $twilio->make_call( + $phone_number, + $connect_url, + $status_callback_url, // Track call status for voicemail detection + $workflow_number // Use queue's phone number as caller ID + ); + if ($result['success']) { // Log the call acceptance TWP_Call_Logger::log_call(array( @@ -390,7 +422,11 @@ class TWP_Agent_Manager { } $twilio = new TWP_Twilio_API(); - return $twilio->send_sms($phone_number, $message); + + // Get SMS from number with proper priority (no workflow context here) + $from_number = TWP_Twilio_API::get_sms_from_number(); + + return $twilio->send_sms($phone_number, $message, $from_number); } return true; diff --git a/includes/class-twp-call-queue.php b/includes/class-twp-call-queue.php index f97e67a..ad94df0 100644 --- a/includes/class-twp-call-queue.php +++ b/includes/class-twp-call-queue.php @@ -32,7 +32,13 @@ class TWP_Call_Queue { array('%d', '%s', '%s', '%s', '%d', '%s') ); - return $result !== false ? $position : false; + if ($result !== false) { + // Notify agents via SMS when a new call enters the queue + self::notify_agents_for_queue($queue_id, $call_data['from_number']); + return $position; + } + + return false; } /** @@ -120,10 +126,17 @@ class TWP_Call_Queue { $table_name = $wpdb->prefix . 'twp_queued_calls'; $queue_table = $wpdb->prefix . 'twp_call_queues'; + error_log('TWP Queue Process: Starting queue processing'); + // Get all active queues $queues = $wpdb->get_results("SELECT * FROM $queue_table"); foreach ($queues as $queue) { + error_log('TWP Queue Process: Processing queue ' . $queue->queue_name . ' (ID: ' . $queue->id . ')'); + + // First, try to assign agents to waiting calls + $this->assign_agents_to_waiting_calls($queue); + // Check for timed out calls $timeout_time = date('Y-m-d H:i:s', strtotime('-' . $queue->timeout_seconds . ' seconds')); @@ -137,6 +150,7 @@ class TWP_Call_Queue { )); foreach ($timed_out_calls as $call) { + error_log('TWP Queue Process: Handling timeout for call ' . $call->call_sid); // Handle timeout $this->handle_timeout($call, $queue); } @@ -144,6 +158,8 @@ class TWP_Call_Queue { // Update caller positions and play position messages $this->update_queue_positions($queue->id); } + + error_log('TWP Queue Process: Finished queue processing'); } /** @@ -170,13 +186,152 @@ class TWP_Call_Queue { $twilio = new TWP_Twilio_API(); $twilio->update_call($call->call_sid, array( - 'Twiml' => $callback_twiml + 'twiml' => $callback_twiml )); // Reorder queue self::reorder_queue($queue->id); } + /** + * Assign agents to waiting calls + */ + private function assign_agents_to_waiting_calls($queue) { + global $wpdb; + $table_name = $wpdb->prefix . 'twp_queued_calls'; + + // Get waiting calls in order + $waiting_calls = $wpdb->get_results($wpdb->prepare( + "SELECT * FROM $table_name + WHERE queue_id = %d AND status = 'waiting' + ORDER BY position ASC", + $queue->id + )); + + if (empty($waiting_calls)) { + return; + } + + error_log('TWP Queue Process: Found ' . count($waiting_calls) . ' waiting calls in queue ' . $queue->queue_name); + + // Get available agents for this queue + $available_agents = $this->get_available_agents_for_queue($queue); + + if (empty($available_agents)) { + error_log('TWP Queue Process: No available agents for queue ' . $queue->queue_name); + return; + } + + error_log('TWP Queue Process: Found ' . count($available_agents) . ' available agents'); + + // Assign agents to calls (one agent per call) + $assignments = 0; + foreach ($waiting_calls as $call) { + if ($assignments >= count($available_agents)) { + break; // No more agents available + } + + $agent = $available_agents[$assignments]; + error_log('TWP Queue Process: Attempting to assign call ' . $call->call_sid . ' to agent ' . $agent['phone']); + + // Try to bridge the call to the agent + if ($this->bridge_call_to_agent($call, $agent, $queue)) { + $assignments++; + error_log('TWP Queue Process: Successfully initiated bridge for call ' . $call->call_sid); + } else { + error_log('TWP Queue Process: Failed to bridge call ' . $call->call_sid . ' to agent'); + } + } + + error_log('TWP Queue Process: Made ' . $assignments . ' call assignments'); + } + + /** + * Get available agents for a queue + */ + private function get_available_agents_for_queue($queue) { + // If queue has assigned agent groups, get agents from those groups + if (!empty($queue->agent_groups)) { + $group_ids = explode(',', $queue->agent_groups); + $agents = array(); + + foreach ($group_ids as $group_id) { + $group_agents = TWP_Agent_Manager::get_available_agents(intval($group_id)); + if ($group_agents) { + $agents = array_merge($agents, $group_agents); + } + } + + return $agents; + } + + // Fallback to all available agents + return TWP_Agent_Manager::get_available_agents(); + } + + /** + * Bridge call to agent + */ + private function bridge_call_to_agent($call, $agent, $queue) { + $twilio = new TWP_Twilio_API(); + + try { + // Create a new call to the agent + $agent_call_data = array( + 'to' => $agent['phone'], + 'from' => $queue->caller_id ?: $call->to_number, // Use queue caller ID or original number + 'url' => home_url('/wp-json/twilio-webhook/v1/agent-connect?' . http_build_query(array( + 'customer_call_sid' => $call->call_sid, + 'customer_number' => $call->from_number, + 'queue_id' => $queue->id, + 'agent_phone' => $agent['phone'], + 'queued_call_id' => $call->id + ))), + 'method' => 'POST', + 'timeout' => 20, + 'statusCallback' => home_url('/wp-json/twilio-webhook/v1/agent-call-status'), + 'statusCallbackEvent' => array('answered', 'completed', 'busy', 'no-answer'), + 'statusCallbackMethod' => 'POST' + ); + + error_log('TWP Queue Bridge: Creating agent call with data: ' . json_encode($agent_call_data)); + + $agent_call_response = $twilio->create_call($agent_call_data); + + if ($agent_call_response['success']) { + // Update call status to indicate agent is being contacted + global $wpdb; + $table_name = $wpdb->prefix . 'twp_queued_calls'; + + $updated = $wpdb->update( + $table_name, + array( + 'status' => 'connecting', + 'agent_phone' => $agent['phone'], + 'agent_call_sid' => $agent_call_response['data']['sid'] + ), + array('call_sid' => $call->call_sid), + array('%s', '%s', '%s'), + array('%s') + ); + + if ($updated) { + error_log('TWP Queue Bridge: Updated call status to connecting'); + return true; + } else { + error_log('TWP Queue Bridge: Failed to update call status'); + } + } else { + error_log('TWP Queue Bridge: Failed to create agent call: ' . ($agent_call_response['error'] ?? 'Unknown error')); + } + + } catch (Exception $e) { + error_log('TWP Queue Bridge: Exception bridging call: ' . $e->getMessage()); + } + + return false; + } + /** * Update queue positions */ @@ -241,7 +396,7 @@ class TWP_Call_Queue { } $twilio->update_call($call->call_sid, array( - 'Twiml' => $twiml->asXML() + 'twiml' => $twiml->asXML() )); } } @@ -278,16 +433,58 @@ class TWP_Call_Queue { global $wpdb; $table_name = $wpdb->prefix . 'twp_call_queues'; - return $wpdb->insert( + $insert_data = array( + 'queue_name' => sanitize_text_field($data['queue_name']), + 'phone_number' => !empty($data['phone_number']) ? sanitize_text_field($data['phone_number']) : '', + 'agent_group_id' => !empty($data['agent_group_id']) ? intval($data['agent_group_id']) : null, + 'max_size' => intval($data['max_size']), + 'wait_music_url' => esc_url_raw($data['wait_music_url']), + 'tts_message' => sanitize_textarea_field($data['tts_message']), + 'timeout_seconds' => intval($data['timeout_seconds']) + ); + + $insert_format = array('%s', '%s'); + if ($insert_data['agent_group_id'] === null) { + $insert_format[] = null; + } else { + $insert_format[] = '%d'; + } + $insert_format = array_merge($insert_format, array('%d', '%s', '%s', '%d')); + + return $wpdb->insert($table_name, $insert_data, $insert_format); + } + + /** + * Update queue + */ + public static function update_queue($queue_id, $data) { + global $wpdb; + $table_name = $wpdb->prefix . 'twp_call_queues'; + + $update_data = array( + 'queue_name' => sanitize_text_field($data['queue_name']), + 'phone_number' => !empty($data['phone_number']) ? sanitize_text_field($data['phone_number']) : '', + 'agent_group_id' => !empty($data['agent_group_id']) ? intval($data['agent_group_id']) : null, + 'max_size' => intval($data['max_size']), + 'wait_music_url' => esc_url_raw($data['wait_music_url']), + 'tts_message' => sanitize_textarea_field($data['tts_message']), + 'timeout_seconds' => intval($data['timeout_seconds']) + ); + + $update_format = array('%s', '%s'); + if ($update_data['agent_group_id'] === null) { + $update_format[] = null; + } else { + $update_format[] = '%d'; + } + $update_format = array_merge($update_format, array('%d', '%s', '%s', '%d')); + + return $wpdb->update( $table_name, - array( - 'queue_name' => sanitize_text_field($data['queue_name']), - 'max_size' => intval($data['max_size']), - 'wait_music_url' => esc_url_raw($data['wait_music_url']), - 'tts_message' => sanitize_textarea_field($data['tts_message']), - 'timeout_seconds' => intval($data['timeout_seconds']) - ), - array('%s', '%d', '%s', '%s', '%d') + $update_data, + array('id' => intval($queue_id)), + $update_format, + array('%d') ); } @@ -358,4 +555,56 @@ class TWP_Call_Queue { return $status; } + + /** + * Notify agents via SMS when a call enters the queue + */ + private static function notify_agents_for_queue($queue_id, $caller_number) { + global $wpdb; + + // Get queue information including assigned agent group and phone number + $queue_table = $wpdb->prefix . 'twp_call_queues'; + $queue = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $queue_table WHERE id = %d", + $queue_id + )); + + if (!$queue || !$queue->agent_group_id) { + error_log("TWP: No agent group assigned to queue {$queue_id}, skipping SMS notifications"); + return; + } + + // Get members of the assigned agent group + require_once dirname(__FILE__) . '/class-twp-agent-groups.php'; + $members = TWP_Agent_Groups::get_group_members($queue->agent_group_id); + + if (empty($members)) { + error_log("TWP: No members found in agent group {$queue->agent_group_id} for queue {$queue_id}"); + return; + } + + $twilio = new TWP_Twilio_API(); + + // Use the queue's phone number as the from number, or fall back to default + $from_number = !empty($queue->phone_number) ? $queue->phone_number : TWP_Twilio_API::get_sms_from_number(); + + if (empty($from_number)) { + error_log("TWP: No SMS from number available for queue notifications"); + return; + } + + $message = "Call waiting in queue '{$queue->queue_name}' from {$caller_number}. Text '1' to this number to receive the next available call."; + + foreach ($members as $member) { + $agent_phone = get_user_meta($member->user_id, 'twp_phone_number', true); + + if (!empty($agent_phone)) { + // Send SMS notification using the queue's phone number + $twilio->send_sms($agent_phone, $message, $from_number); + + // Log the notification + error_log("TWP: Queue SMS notification sent to agent {$member->user_id} at {$agent_phone} from {$from_number} for queue {$queue_id}"); + } + } + } } \ No newline at end of file diff --git a/includes/class-twp-callback-manager.php b/includes/class-twp-callback-manager.php index 7cc6cf6..9db5183 100644 --- a/includes/class-twp-callback-manager.php +++ b/includes/class-twp-callback-manager.php @@ -282,7 +282,11 @@ class TWP_Callback_Manager { */ private static function send_sms($to_number, $message) { $twilio = new TWP_Twilio_API(); - return $twilio->send_sms($to_number, $message); + + // Get SMS from number with proper priority (no workflow context here) + $from_number = TWP_Twilio_API::get_sms_from_number(); + + return $twilio->send_sms($to_number, $message, $from_number); } /** diff --git a/includes/class-twp-core.php b/includes/class-twp-core.php index a98d2f8..58a12d8 100644 --- a/includes/class-twp-core.php +++ b/includes/class-twp-core.php @@ -77,7 +77,10 @@ class TWP_Core { // AJAX handlers $this->loader->add_action('wp_ajax_twp_save_schedule', $plugin_admin, 'ajax_save_schedule'); $this->loader->add_action('wp_ajax_twp_delete_schedule', $plugin_admin, 'ajax_delete_schedule'); + $this->loader->add_action('wp_ajax_twp_get_schedules', $plugin_admin, 'ajax_get_schedules'); + $this->loader->add_action('wp_ajax_twp_get_schedule', $plugin_admin, 'ajax_get_schedule'); $this->loader->add_action('wp_ajax_twp_save_workflow', $plugin_admin, 'ajax_save_workflow'); + $this->loader->add_action('wp_ajax_twp_update_workflow', $plugin_admin, 'ajax_save_workflow'); $this->loader->add_action('wp_ajax_twp_get_workflow', $plugin_admin, 'ajax_get_workflow'); $this->loader->add_action('wp_ajax_twp_delete_workflow', $plugin_admin, 'ajax_delete_workflow'); $this->loader->add_action('wp_ajax_twp_test_call', $plugin_admin, 'ajax_test_call'); @@ -106,6 +109,7 @@ class TWP_Core { $this->loader->add_action('wp_ajax_twp_get_voicemail', $plugin_admin, 'ajax_get_voicemail'); $this->loader->add_action('wp_ajax_twp_delete_voicemail', $plugin_admin, 'ajax_delete_voicemail'); $this->loader->add_action('wp_ajax_twp_transcribe_voicemail', $plugin_admin, 'ajax_transcribe_voicemail'); + $this->loader->add_action('wp_ajax_twp_get_voicemail_audio', $plugin_admin, 'ajax_get_voicemail_audio'); // Agent group management AJAX $this->loader->add_action('wp_ajax_twp_get_all_groups', $plugin_admin, 'ajax_get_all_groups'); @@ -120,12 +124,17 @@ class TWP_Core { $this->loader->add_action('wp_ajax_twp_accept_call', $plugin_admin, 'ajax_accept_call'); $this->loader->add_action('wp_ajax_twp_get_waiting_calls', $plugin_admin, 'ajax_get_waiting_calls'); $this->loader->add_action('wp_ajax_twp_set_agent_status', $plugin_admin, 'ajax_set_agent_status'); + $this->loader->add_action('wp_ajax_twp_get_call_details', $plugin_admin, 'ajax_get_call_details'); // Callback and outbound call AJAX $this->loader->add_action('wp_ajax_twp_request_callback', $plugin_admin, 'ajax_request_callback'); $this->loader->add_action('wp_ajax_twp_initiate_outbound_call', $plugin_admin, 'ajax_initiate_outbound_call'); $this->loader->add_action('wp_ajax_twp_initiate_outbound_call_with_from', $plugin_admin, 'ajax_initiate_outbound_call_with_from'); $this->loader->add_action('wp_ajax_twp_get_callbacks', $plugin_admin, 'ajax_get_callbacks'); + + // Phone number maintenance + $this->loader->add_action('wp_ajax_twp_update_phone_status_callbacks', $plugin_admin, 'ajax_update_phone_status_callbacks'); + $this->loader->add_action('wp_ajax_twp_toggle_number_status_callback', $plugin_admin, 'ajax_toggle_number_status_callback'); } /** @@ -149,6 +158,9 @@ class TWP_Core { // Callback processing $this->loader->add_action('twp_process_callbacks', 'TWP_Callback_Manager', 'process_callbacks'); + // Call queue cleanup + $this->loader->add_action('twp_cleanup_old_calls', $this, 'cleanup_old_calls'); + // Schedule cron events if (!wp_next_scheduled('twp_check_schedules')) { wp_schedule_event(time(), 'twp_every_minute', 'twp_check_schedules'); @@ -161,6 +173,10 @@ class TWP_Core { if (!wp_next_scheduled('twp_process_callbacks')) { wp_schedule_event(time(), 'twp_every_minute', 'twp_process_callbacks'); } + + if (!wp_next_scheduled('twp_cleanup_old_calls')) { + wp_schedule_event(time(), 'hourly', 'twp_cleanup_old_calls'); + } } /** @@ -199,6 +215,10 @@ class TWP_Core { * Run the loader */ public function run() { + // Initialize webhooks + $webhooks = new TWP_Webhooks(); + $webhooks->register_endpoints(); + // Add custom cron schedules add_filter('cron_schedules', function($schedules) { $schedules['twp_every_minute'] = array( @@ -228,4 +248,83 @@ class TWP_Core { public function get_version() { return $this->version; } + + /** + * Cleanup old stuck calls + */ + public function cleanup_old_calls() { + global $wpdb; + $calls_table = $wpdb->prefix . 'twp_queued_calls'; + + // Clean up calls that have been in 'answered' status for more than 2 hours + // These are likely stuck due to missed webhooks or other issues + $updated = $wpdb->query( + "UPDATE $calls_table + SET status = 'completed', ended_at = NOW() + WHERE status = 'answered' + AND joined_at < DATE_SUB(NOW(), INTERVAL 2 HOUR)" + ); + + if ($updated > 0) { + error_log("TWP Cleanup: Updated {$updated} stuck calls from 'answered' to 'completed' status"); + } + + // Backup check for waiting calls (status callbacks should handle most cases) + // Only check older calls that might have missed status callbacks + $waiting_calls = $wpdb->get_results( + "SELECT call_sid FROM $calls_table + WHERE status = 'waiting' + AND joined_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE) + AND joined_at > DATE_SUB(NOW(), INTERVAL 6 HOUR) + LIMIT 5" + ); + + if (!empty($waiting_calls)) { + $twilio = new TWP_Twilio_API(); + $cleaned_count = 0; + + foreach ($waiting_calls as $call) { + try { + // Check call status with Twilio + $call_info = $twilio->get_call_info($call->call_sid); + + if ($call_info && isset($call_info['status'])) { + // If call is completed, busy, failed, or canceled, remove from queue + if (in_array($call_info['status'], ['completed', 'busy', 'failed', 'canceled'])) { + $wpdb->update( + $calls_table, + array( + 'status' => 'hangup', + 'ended_at' => current_time('mysql') + ), + array('call_sid' => $call->call_sid), + array('%s', '%s'), + array('%s') + ); + $cleaned_count++; + error_log("TWP Cleanup: Detected ended call {$call->call_sid} with status {$call_info['status']}"); + } + } + } catch (Exception $e) { + error_log("TWP Cleanup: Error checking call {$call->call_sid}: " . $e->getMessage()); + } + } + + if ($cleaned_count > 0) { + error_log("TWP Cleanup: Cleaned up {$cleaned_count} ended calls from queue"); + } + } + + // Clean up very old waiting calls (older than 24 hours) - these are likely orphaned + $updated_waiting = $wpdb->query( + "UPDATE $calls_table + SET status = 'timeout', ended_at = NOW() + WHERE status = 'waiting' + AND joined_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)" + ); + + if ($updated_waiting > 0) { + error_log("TWP Cleanup: Updated {$updated_waiting} old waiting calls to 'timeout' status"); + } + } } \ No newline at end of file diff --git a/includes/class-twp-elevenlabs-api.php b/includes/class-twp-elevenlabs-api.php index 9ffdf05..a4d6713 100644 --- a/includes/class-twp-elevenlabs-api.php +++ b/includes/class-twp-elevenlabs-api.php @@ -22,6 +22,12 @@ class TWP_ElevenLabs_API { * Convert text to speech */ public function text_to_speech($text, $voice_id = null) { + // Handle both string voice_id and options array + if (is_array($voice_id)) { + $options = $voice_id; + $voice_id = isset($options['voice_id']) ? $options['voice_id'] : null; + } + if (!$voice_id) { $voice_id = $this->voice_id; } diff --git a/includes/class-twp-scheduler.php b/includes/class-twp-scheduler.php index f6c77aa..9d34921 100644 --- a/includes/class-twp-scheduler.php +++ b/includes/class-twp-scheduler.php @@ -11,8 +11,12 @@ class TWP_Scheduler { global $wpdb; $table_name = $wpdb->prefix . 'twp_phone_schedules'; - $current_time = current_time('H:i:s'); - $current_day = strtolower(date('l')); + // Use WordPress timezone + $wp_timezone = wp_timezone(); + $current_datetime = new DateTime('now', $wp_timezone); + + $current_time = $current_datetime->format('H:i:s'); + $current_day = strtolower($current_datetime->format('l')); $schedules = $wpdb->get_results($wpdb->prepare( "SELECT * FROM $table_name @@ -43,7 +47,7 @@ class TWP_Scheduler { foreach ($numbers['data']['incoming_phone_numbers'] as $number) { if ($number['phone_number'] == $schedule->phone_number) { // Configure webhook based on schedule - $webhook_url = home_url('/twilio-webhook/voice'); + $webhook_url = home_url('/wp-json/twilio-webhook/v1/voice'); $webhook_url = add_query_arg('schedule_id', $schedule->id, $webhook_url); $twilio->configure_phone_number( @@ -64,16 +68,39 @@ class TWP_Scheduler { global $wpdb; $table_name = $wpdb->prefix . 'twp_phone_schedules'; + // Debug logging - ensure tables exist and columns are correct + TWP_Activator::ensure_tables_exist(); + + // Force column update check + global $wpdb; + $table_schedules = $wpdb->prefix . 'twp_phone_schedules'; + $column_info = $wpdb->get_results("SHOW COLUMNS FROM $table_schedules LIKE 'days_of_week'"); + if (!empty($column_info)) { + error_log('TWP Create Schedule: Current days_of_week column type: ' . $column_info[0]->Type); + if ($column_info[0]->Type === 'varchar(20)') { + error_log('TWP Create Schedule: Expanding days_of_week column to varchar(100)'); + $wpdb->query("ALTER TABLE $table_schedules MODIFY COLUMN days_of_week varchar(100) NOT NULL"); + } + } + + error_log('TWP Create Schedule: Input data: ' . print_r($data, true)); + error_log('TWP Create Schedule: Table name: ' . $table_name); + $insert_data = array( 'schedule_name' => sanitize_text_field($data['schedule_name']), 'days_of_week' => sanitize_text_field($data['days_of_week']), 'start_time' => sanitize_text_field($data['start_time']), 'end_time' => sanitize_text_field($data['end_time']), - 'workflow_id' => sanitize_text_field($data['workflow_id']), 'is_active' => isset($data['is_active']) ? 1 : 0 ); - $format = array('%s', '%s', '%s', '%s', '%s', '%d'); + $format = array('%s', '%s', '%s', '%s', '%d'); + + // Add workflow_id if provided + if ($data['workflow_id']) { + $insert_data['workflow_id'] = sanitize_text_field($data['workflow_id']); + $format[] = '%s'; + } // Add optional fields if provided if (!empty($data['phone_number'])) { @@ -101,8 +128,24 @@ class TWP_Scheduler { $format[] = '%s'; } + if (isset($data['holiday_dates'])) { + $insert_data['holiday_dates'] = sanitize_textarea_field($data['holiday_dates']); + $format[] = '%s'; + } + + error_log('TWP Create Schedule: Insert data: ' . print_r($insert_data, true)); + error_log('TWP Create Schedule: Insert format: ' . print_r($format, true)); + $result = $wpdb->insert($table_name, $insert_data, $format); + error_log('TWP Create Schedule: Insert result: ' . ($result ? 'true' : 'false')); + if ($result === false) { + error_log('TWP Create Schedule: Database error: ' . $wpdb->last_error); + } else { + $new_id = $wpdb->insert_id; + error_log('TWP Create Schedule: New schedule ID: ' . $new_id); + } + return $result !== false; } @@ -166,6 +209,11 @@ class TWP_Scheduler { $update_format[] = '%s'; } + if (isset($data['holiday_dates'])) { + $update_data['holiday_dates'] = sanitize_textarea_field($data['holiday_dates']); + $update_format[] = '%s'; + } + if (isset($data['is_active'])) { $update_data['is_active'] = $data['is_active'] ? 1 : 0; $update_format[] = '%d'; @@ -233,19 +281,58 @@ class TWP_Scheduler { $schedule = self::get_schedule($schedule_id); if (!$schedule || !$schedule->is_active) { + error_log('TWP Schedule: Schedule not found or inactive - ID: ' . $schedule_id); return false; } - $current_time = current_time('H:i:s'); - $current_day = strtolower(date('l')); + // Use WordPress timezone for all date/time operations + $wp_timezone = wp_timezone(); + $current_datetime = new DateTime('now', $wp_timezone); + + $current_time = $current_datetime->format('H:i:s'); + $current_day = strtolower($current_datetime->format('l')); // Monday, Tuesday, etc. + $current_date = $current_datetime->format('Y-m-d'); + + error_log('TWP Schedule: Checking schedule "' . $schedule->schedule_name . '" - Current time: ' . $current_time . ', Current day: ' . $current_day . ', WP Timezone: ' . $wp_timezone->getName()); + error_log('TWP Schedule: Schedule days: ' . $schedule->days_of_week . ', Schedule time: ' . $schedule->start_time . ' - ' . $schedule->end_time); + + // Check if today is a holiday + if (self::is_holiday($schedule, $current_date)) { + error_log('TWP Schedule: Today is a holiday - treating as after-hours'); + return false; // Treat holidays as after-hours + } // Check if current day is in schedule if (strpos($schedule->days_of_week, $current_day) === false) { + error_log('TWP Schedule: Current day (' . $current_day . ') not in schedule days (' . $schedule->days_of_week . ')'); return false; } // Check if current time is within schedule - return $current_time >= $schedule->start_time && $current_time <= $schedule->end_time; + $is_within_hours = $current_time >= $schedule->start_time && $current_time <= $schedule->end_time; + error_log('TWP Schedule: Time check - Current: ' . $current_time . ', Start: ' . $schedule->start_time . ', End: ' . $schedule->end_time . ', Within hours: ' . ($is_within_hours ? 'YES' : 'NO')); + + return $is_within_hours; + } + + /** + * Check if today is a holiday for this schedule + */ + private static function is_holiday($schedule, $current_date = null) { + if (empty($schedule->holiday_dates)) { + return false; + } + + if ($current_date === null) { + // Use WordPress timezone + $wp_timezone = wp_timezone(); + $current_datetime = new DateTime('now', $wp_timezone); + $current_date = $current_datetime->format('Y-m-d'); + } + + $holidays = array_map('trim', explode(',', $schedule->holiday_dates)); + + return in_array($current_date, $holidays); } /** diff --git a/includes/class-twp-twilio-api.php b/includes/class-twp-twilio-api.php index 64eca03..6a1e46a 100644 --- a/includes/class-twp-twilio-api.php +++ b/includes/class-twp-twilio-api.php @@ -12,7 +12,29 @@ class TWP_Twilio_API { */ public function __construct() { $this->init_sdk_client(); - $this->phone_number = get_option('twp_twilio_phone_number'); + // Try to get the SMS notification number first, or get the first available Twilio number + $this->phone_number = get_option('twp_sms_notification_number'); + + // If no SMS number configured, try to get the first phone number from the account + if (empty($this->phone_number)) { + $this->phone_number = $this->get_default_phone_number(); + } + } + + /** + * Get the default phone number from the account + */ + private function get_default_phone_number() { + try { + // Get the first phone number from the account + $numbers = $this->client->incomingPhoneNumbers->read([], 1); + if (!empty($numbers)) { + return $numbers[0]->phoneNumber; + } + } catch (\Exception $e) { + error_log('TWP: Unable to get default phone number: ' . $e->getMessage()); + } + return null; } /** @@ -72,6 +94,10 @@ class TWP_Twilio_API { if ($status_callback) { $params['statusCallback'] = $status_callback; $params['statusCallbackEvent'] = ['initiated', 'ringing', 'answered', 'completed']; + $params['statusCallbackMethod'] = 'POST'; + $params['timeout'] = 20; // Ring for 20 seconds before giving up + $params['machineDetection'] = 'Enable'; // Detect if voicemail answers + $params['machineDetectionTimeout'] = 30; // Wait 30 seconds to detect machine } $call = $this->client->calls->create( @@ -238,10 +264,22 @@ class TWP_Twilio_API { */ public function send_sms($to_number, $message, $from_number = null) { try { + // Determine the from number + $from = $from_number ?: $this->phone_number; + + // Validate we have a from number + if (empty($from)) { + error_log('TWP SMS Error: No from number available. Please configure SMS notification number in settings.'); + return [ + 'success' => false, + 'error' => 'No SMS from number configured. Please set SMS notification number in plugin settings.' + ]; + } + $sms = $this->client->messages->create( $to_number, [ - 'from' => $from_number ?: $this->phone_number, + 'from' => $from, 'body' => $message ] ); @@ -282,6 +320,7 @@ class TWP_Twilio_API { 'friendly_name' => $number->friendlyName ?: $number->phoneNumber ?: 'Unknown', 'voice_url' => $number->voiceUrl ?: '', 'sms_url' => $number->smsUrl ?: '', + 'status_callback_url' => $number->statusCallback ?: '', 'capabilities' => [ 'voice' => $number->capabilities ? (bool)$number->capabilities->getVoice() : false, 'sms' => $number->capabilities ? (bool)$number->capabilities->getSms() : false, @@ -372,6 +411,11 @@ class TWP_Twilio_API { $params['smsMethod'] = 'POST'; } + // Add status callback for real-time call state tracking + $status_callback_url = home_url('/wp-json/twilio-webhook/v1/status'); + $params['statusCallback'] = $status_callback_url; + $params['statusCallbackMethod'] = 'POST'; + $number = $this->client->incomingPhoneNumbers->create($params); return [ @@ -430,6 +474,11 @@ class TWP_Twilio_API { $params['smsMethod'] = 'POST'; } + // Add status callback for real-time call state tracking + $status_callback_url = home_url('/wp-json/twilio-webhook/v1/status'); + $params['statusCallback'] = $status_callback_url; + $params['statusCallbackMethod'] = 'POST'; + $number = $this->client->incomingPhoneNumbers($phone_sid)->update($params); return [ @@ -464,6 +513,34 @@ class TWP_Twilio_API { return $this->client; } + /** + * Get SMS from number with proper priority + */ + public static function get_sms_from_number($workflow_id = null) { + // Priority 1: If we have a workflow_id, get the workflow's phone number + if ($workflow_id) { + $workflow = TWP_Workflow::get_workflow($workflow_id); + if ($workflow && !empty($workflow->phone_number)) { + return $workflow->phone_number; + } + } + + // Priority 2: Use default SMS number setting + $default_sms_number = get_option('twp_default_sms_number'); + if (!empty($default_sms_number)) { + return $default_sms_number; + } + + // Priority 3: Fall back to first available Twilio number + $twilio = new self(); + $phone_numbers = $twilio->get_phone_numbers(); + if ($phone_numbers['success'] && !empty($phone_numbers['data']['incoming_phone_numbers'])) { + return $phone_numbers['data']['incoming_phone_numbers'][0]['phone_number']; + } + + return null; + } + /** * Validate webhook signature */ @@ -471,4 +548,110 @@ class TWP_Twilio_API { $validator = new \Twilio\Security\RequestValidator(get_option('twp_twilio_auth_token')); return $validator->validate($signature, $url, $params); } + + /** + * Get call information from Twilio + */ + public function get_call_info($call_sid) { + try { + $call = $this->client->calls($call_sid)->fetch(); + + return [ + 'sid' => $call->sid, + 'status' => $call->status, + 'from' => $call->from, + 'to' => $call->to, + 'duration' => $call->duration, + 'start_time' => $call->startTime ? $call->startTime->format('Y-m-d H:i:s') : null, + 'end_time' => $call->endTime ? $call->endTime->format('Y-m-d H:i:s') : null, + 'direction' => $call->direction, + 'price' => $call->price, + 'priceUnit' => $call->priceUnit + ]; + } catch (\Twilio\Exceptions\TwilioException $e) { + error_log('TWP: Error fetching call info for ' . $call_sid . ': ' . $e->getMessage()); + return null; + } + } + + /** + * Toggle status callback for a specific phone number + */ + public function toggle_number_status_callback($phone_sid, $enable = true) { + try { + $params = []; + + if ($enable) { + $params['statusCallback'] = home_url('/wp-json/twilio-webhook/v1/status'); + $params['statusCallbackMethod'] = 'POST'; + } else { + // Clear the status callback + $params['statusCallback'] = ''; + } + + $number = $this->client->incomingPhoneNumbers($phone_sid)->update($params); + + return [ + 'success' => true, + 'data' => [ + 'sid' => $number->sid, + 'phone_number' => $number->phoneNumber, + 'status_callback' => $number->statusCallback, + 'enabled' => !empty($number->statusCallback) + ] + ]; + } catch (\Twilio\Exceptions\TwilioException $e) { + return [ + 'success' => false, + 'error' => $e->getMessage() + ]; + } + } + + /** + * Update all existing phone numbers to include status callbacks + */ + public function enable_status_callbacks_for_all_numbers() { + try { + $numbers = $this->get_phone_numbers(); + if (!$numbers['success']) { + return [ + 'success' => false, + 'error' => 'Failed to retrieve phone numbers: ' . $numbers['error'] + ]; + } + + $status_callback_url = home_url('/wp-json/twilio-webhook/v1/status'); + $updated_count = 0; + $errors = []; + + foreach ($numbers['data']['incoming_phone_numbers'] as $number) { + try { + $this->client->incomingPhoneNumbers($number['sid'])->update([ + 'statusCallback' => $status_callback_url, + 'statusCallbackMethod' => 'POST' + ]); + $updated_count++; + error_log('TWP: Added status callback to phone number: ' . $number['phone_number']); + } catch (\Twilio\Exceptions\TwilioException $e) { + $errors[] = 'Failed to update ' . $number['phone_number'] . ': ' . $e->getMessage(); + error_log('TWP: Error updating phone number ' . $number['phone_number'] . ': ' . $e->getMessage()); + } + } + + return [ + 'success' => true, + 'data' => [ + 'updated_count' => $updated_count, + 'total_numbers' => count($numbers['data']['incoming_phone_numbers']), + 'errors' => $errors + ] + ]; + } catch (\Twilio\Exceptions\TwilioException $e) { + return [ + 'success' => false, + 'error' => $e->getMessage() + ]; + } + } } \ No newline at end of file diff --git a/includes/class-twp-webhooks.php b/includes/class-twp-webhooks.php index d2956b8..d7d82ee 100644 --- a/includes/class-twp-webhooks.php +++ b/includes/class-twp-webhooks.php @@ -4,6 +4,19 @@ */ class TWP_Webhooks { + /** + * Constructor - ensure Twilio SDK is loaded + */ + public function __construct() { + // Load Twilio SDK if not already loaded + if (!class_exists('\Twilio\Rest\Client')) { + $autoloader_path = plugin_dir_path(dirname(__FILE__)) . 'vendor/autoload.php'; + if (file_exists($autoloader_path)) { + require_once $autoloader_path; + } + } + } + /** * Register webhook endpoints */ @@ -45,6 +58,13 @@ class TWP_Webhooks { 'permission_callback' => '__return_true' )); + // Queue action webhook (for enqueue/dequeue events) + register_rest_route('twilio-webhook/v1', '/queue-action', array( + 'methods' => 'POST', + 'callback' => array($this, 'handle_queue_action'), + 'permission_callback' => '__return_true' + )); + // Voicemail callback webhook register_rest_route('twilio-webhook/v1', '/voicemail-callback', array( 'methods' => 'POST', @@ -52,6 +72,51 @@ class TWP_Webhooks { 'permission_callback' => '__return_true' )); + // Voicemail complete webhook (after recording) + register_rest_route('twilio-webhook/v1', '/voicemail-complete', array( + 'methods' => 'POST', + 'callback' => array($this, 'handle_voicemail_complete'), + 'permission_callback' => '__return_true' + )); + + // Agent call status webhook (detect voicemail/no-answer) + register_rest_route('twilio-webhook/v1', '/agent-call-status', array( + 'methods' => 'POST', + 'callback' => array($this, 'handle_agent_call_status'), + 'permission_callback' => '__return_true' + )); + + // Agent screening webhook (screen agent before connecting) + register_rest_route('twilio-webhook/v1', '/agent-screen', array( + 'methods' => 'POST', + 'callback' => array($this, 'handle_agent_screen'), + 'permission_callback' => '__return_true' + )); + + // Agent confirmation webhook (after agent presses key) + register_rest_route('twilio-webhook/v1', '/agent-confirm', array( + 'methods' => 'POST', + 'callback' => array($this, 'handle_agent_confirm'), + 'permission_callback' => '__return_true' + )); + + // Voicemail audio proxy endpoint + register_rest_route('twilio-webhook/v1', '/voicemail-audio/(?P\d+)', array( + 'methods' => 'GET', + 'callback' => array($this, 'proxy_voicemail_audio'), + 'permission_callback' => function() { + // Check if user is logged in with proper permissions + return is_user_logged_in() && current_user_can('manage_options'); + }, + 'args' => array( + 'id' => array( + 'validate_callback' => function($param, $request, $key) { + return is_numeric($param); + } + ), + ), + )); + // Transcription webhook register_rest_route('twilio-webhook/v1', '/transcription', array( 'methods' => 'POST', @@ -129,9 +194,10 @@ class TWP_Webhooks { * Send TwiML response */ private function send_twiml_response($twiml) { - return new WP_REST_Response($twiml, 200, array( - 'Content-Type' => 'text/xml; charset=utf-8' - )); + // Send raw XML response for Twilio + header('Content-Type: text/xml; charset=utf-8'); + echo $twiml; + exit; } /** @@ -250,19 +316,29 @@ class TWP_Webhooks { /** * Handle SMS webhook */ - private function handle_sms_webhook() { + public function handle_sms_webhook($request) { + error_log('TWP SMS Webhook: ===== WEBHOOK TRIGGERED ====='); + error_log('TWP SMS Webhook: Request method: ' . $_SERVER['REQUEST_METHOD']); + error_log('TWP SMS Webhook: Request URI: ' . $_SERVER['REQUEST_URI']); + + $params = $request->get_params(); $sms_data = array( - 'MessageSid' => isset($_POST['MessageSid']) ? $_POST['MessageSid'] : '', - 'From' => isset($_POST['From']) ? $_POST['From'] : '', - 'To' => isset($_POST['To']) ? $_POST['To'] : '', - 'Body' => isset($_POST['Body']) ? $_POST['Body'] : '' + 'MessageSid' => isset($params['MessageSid']) ? $params['MessageSid'] : '', + 'From' => isset($params['From']) ? $params['From'] : '', + 'To' => isset($params['To']) ? $params['To'] : '', + 'Body' => isset($params['Body']) ? $params['Body'] : '' ); + error_log('TWP SMS Webhook: Raw POST data: ' . print_r($_POST, true)); + error_log('TWP SMS Webhook: Parsed params: ' . print_r($params, true)); + error_log('TWP SMS Webhook: Received SMS - From: ' . $sms_data['From'] . ', To: ' . $sms_data['To'] . ', Body: ' . $sms_data['Body']); + // Process SMS commands $command = strtolower(trim($sms_data['Body'])); switch ($command) { case '1': + error_log('TWP SMS Webhook: Agent texted "1" - calling handle_agent_ready_sms'); $this->handle_agent_ready_sms($sms_data['From']); break; @@ -310,9 +386,13 @@ class TWP_Webhooks { )); // Update call status in queue if applicable - if ($status_data['CallStatus'] === 'completed') { - TWP_Call_Queue::remove_from_queue($status_data['CallSid']); - TWP_Call_Logger::log_action($status_data['CallSid'], 'Call removed from queue'); + // Remove from queue for any terminal call state + if (in_array($status_data['CallStatus'], ['completed', 'busy', 'failed', 'canceled', 'no-answer'])) { + $queue_removed = TWP_Call_Queue::remove_from_queue($status_data['CallSid']); + if ($queue_removed) { + TWP_Call_Logger::log_action($status_data['CallSid'], 'Call removed from queue due to status: ' . $status_data['CallStatus']); + error_log('TWP Status Webhook: Removed call ' . $status_data['CallSid'] . ' from queue (status: ' . $status_data['CallStatus'] . ')'); + } } // Empty response @@ -390,9 +470,13 @@ class TWP_Webhooks { /** * Handle queue wait */ - private function handle_queue_wait() { - $queue_id = isset($_GET['queue_id']) ? intval($_GET['queue_id']) : 0; - $call_sid = isset($_POST['CallSid']) ? $_POST['CallSid'] : ''; + public function handle_queue_wait($request = null) { + // Get parameters from request + $params = $request ? $request->get_params() : $_REQUEST; + $queue_id = isset($params['queue_id']) ? intval($params['queue_id']) : 0; + $call_sid = isset($params['CallSid']) ? $params['CallSid'] : ''; + + error_log('TWP Queue Wait: queue_id=' . $queue_id . ', call_sid=' . $call_sid); // Get caller's position in queue global $wpdb; @@ -405,34 +489,211 @@ class TWP_Webhooks { if ($call) { $position = $call->position; - $elevenlabs = new TWP_ElevenLabs_API(); + $status = $call->status; + error_log('TWP Queue Wait: Found call in position ' . $position . ' with status ' . $status); - // Generate position announcement - $message = "You are currently number $position in the queue. Your call is important to us."; - $audio_result = $elevenlabs->text_to_speech($message); + // If call is being connected to an agent, provide different response + if ($status === 'connecting' || $status === 'answered') { + error_log('TWP Queue Wait: Call is being connected to agent, providing hold message'); + $twiml = new SimpleXMLElement(''); + $say = $twiml->addChild('Say', 'We found an available agent. Please hold while we connect you.'); + $say->addAttribute('voice', 'alice'); + + // Add music or pause while connecting + $queue = TWP_Call_Queue::get_queue($queue_id); + if ($queue && !empty($queue->wait_music_url)) { + $play = $twiml->addChild('Play', $queue->wait_music_url); + } else { + $pause = $twiml->addChild('Pause'); + $pause->addAttribute('length', '30'); + } + + return $this->send_twiml_response($twiml->asXML()); + } + // For waiting calls, continue with normal queue behavior + // Create basic TwiML response $twiml = new SimpleXMLElement(''); - if ($audio_result['success']) { - $play = $twiml->addChild('Play', $audio_result['file_url']); - } else { + // Simple position announcement + if ($position > 1) { + $message = "You are currently number $position in the queue. Please continue to hold."; $say = $twiml->addChild('Say', $message); $say->addAttribute('voice', 'alice'); } - // Add wait music + // Add wait music or pause, then redirect back to continue the loop $queue = TWP_Call_Queue::get_queue($queue_id); - if ($queue && $queue->wait_music_url) { + if ($queue && !empty($queue->wait_music_url)) { $play = $twiml->addChild('Play', $queue->wait_music_url); - $play->addAttribute('loop', '0'); + } else { + // Add a pause to prevent rapid loops + $pause = $twiml->addChild('Pause'); + $pause->addAttribute('length', '15'); // 15 second pause } - echo $twiml->asXML(); + // Redirect back to this same endpoint to create continuous loop + $redirect_url = home_url('/wp-json/twilio-webhook/v1/queue-wait'); + $redirect_url = add_query_arg(array( + 'queue_id' => $queue_id, + 'call_sid' => urlencode($call_sid) // URL encode to handle special characters + ), $redirect_url); + + // Set the text content of Redirect element properly + $redirect = $twiml->addChild('Redirect'); + $redirect[0] = $redirect_url; // Set the URL as the text content + $redirect->addAttribute('method', 'POST'); + + + $response = $twiml->asXML(); + error_log('TWP Queue Wait: Returning continuous TwiML: ' . $response); + + return $this->send_twiml_response($response); } else { - $this->send_default_response(); + error_log('TWP Queue Wait: Call not found in queue - providing basic hold'); + + // Call not in queue yet (maybe still being processed) - provide basic hold with redirect + $twiml = new SimpleXMLElement(''); + $say = $twiml->addChild('Say', 'Please hold while we process your call.'); + $say->addAttribute('voice', 'alice'); + + // Add a pause then redirect to check again + $pause = $twiml->addChild('Pause'); + $pause->addAttribute('length', '10'); // 10 second pause + + // Redirect back to check if call has been added to queue + $redirect_url = home_url('/wp-json/twilio-webhook/v1/queue-wait'); + $redirect_url = add_query_arg(array( + 'queue_id' => $queue_id, + 'call_sid' => urlencode($call_sid) // URL encode to handle special characters + ), $redirect_url); + + // Set the text content of Redirect element properly + $redirect = $twiml->addChild('Redirect'); + $redirect[0] = $redirect_url; // Set the URL as the text content + $redirect->addAttribute('method', 'POST'); + + + return $this->send_twiml_response($twiml->asXML()); } } + /** + * Handle queue action (enqueue/dequeue events) + */ + public function handle_queue_action($request = null) { + $params = $request ? $request->get_params() : $_REQUEST; + $queue_id = isset($params['queue_id']) ? intval($params['queue_id']) : 0; + $call_sid = isset($params['CallSid']) ? $params['CallSid'] : ''; + $queue_result = isset($params['QueueResult']) ? $params['QueueResult'] : ''; + $from_number = isset($params['From']) ? $params['From'] : ''; + $to_number = isset($params['To']) ? $params['To'] : ''; + + error_log('TWP Queue Action: queue_id=' . $queue_id . ', call_sid=' . $call_sid . ', result=' . $queue_result); + + // Call left queue (answered, timeout, hangup, etc.) - update status + global $wpdb; + $table_name = $wpdb->prefix . 'twp_queued_calls'; + + $status = 'completed'; + if ($queue_result === 'timeout') { + $status = 'timeout'; + } elseif ($queue_result === 'hangup') { + $status = 'hangup'; + } elseif ($queue_result === 'bridged') { + $status = 'answered'; + } elseif ($queue_result === 'leave') { + $status = 'transferred'; + } + + $updated = $wpdb->update( + $table_name, + array( + 'status' => $status, + 'ended_at' => current_time('mysql') + ), + array('call_sid' => $call_sid), + array('%s', '%s'), + array('%s') + ); + + if ($updated) { + error_log('TWP Queue Action: Updated call status to ' . $status); + } else { + error_log('TWP Queue Action: No call found to update with SID ' . $call_sid); + } + + // Return empty response - this is just for tracking + return $this->send_twiml_response(''); + } + + /** + * Handle voicemail complete (after recording) + */ + public function handle_voicemail_complete($request) { + $twiml = ''; + $twiml .= ''; + $twiml .= 'Thank you for your message. Goodbye.'; + $twiml .= ''; + $twiml .= ''; + + return $this->send_twiml_response($twiml); + } + + /** + * Proxy voicemail audio through WordPress + */ + public function proxy_voicemail_audio($request) { + // Permission already checked by REST API permission_callback + $voicemail_id = intval($request->get_param('id')); + + global $wpdb; + $table_name = $wpdb->prefix . 'twp_voicemails'; + + $voicemail = $wpdb->get_row($wpdb->prepare( + "SELECT recording_url FROM $table_name WHERE id = %d", + $voicemail_id + )); + + if (!$voicemail || !$voicemail->recording_url) { + header('HTTP/1.0 404 Not Found'); + exit('Voicemail not found'); + } + + // Fetch the audio from Twilio using authenticated request + $twilio = new TWP_Twilio_API(); + $account_sid = get_option('twp_twilio_account_sid'); + $auth_token = get_option('twp_twilio_auth_token'); + + // Add .mp3 to the URL if not present + $audio_url = $voicemail->recording_url; + if (strpos($audio_url, '.mp3') === false && strpos($audio_url, '.wav') === false) { + $audio_url .= '.mp3'; + } + + // Fetch audio with authentication + $response = wp_remote_get($audio_url, array( + 'headers' => array( + 'Authorization' => 'Basic ' . base64_encode($account_sid . ':' . $auth_token) + ), + 'timeout' => 30 + )); + + if (is_wp_error($response)) { + return new WP_Error('fetch_error', 'Unable to fetch audio', array('status' => 500)); + } + + $body = wp_remote_retrieve_body($response); + $content_type = wp_remote_retrieve_header($response, 'content-type') ?: 'audio/mpeg'; + + // Return audio with proper headers + header('Content-Type: ' . $content_type); + header('Content-Length: ' . strlen($body)); + header('Cache-Control: private, max-age=3600'); + echo $body; + exit; + } + /** * Handle voicemail callback */ @@ -444,12 +705,32 @@ class TWP_Webhooks { $params = $request->get_params(); + // Debug logging + error_log('TWP Voicemail Callback Params: ' . json_encode($params)); + $recording_url = isset($params['RecordingUrl']) ? $params['RecordingUrl'] : ''; $recording_duration = isset($params['RecordingDuration']) ? intval($params['RecordingDuration']) : 0; $call_sid = isset($params['CallSid']) ? $params['CallSid'] : ''; $from = isset($params['From']) ? $params['From'] : ''; $workflow_id = isset($params['workflow_id']) ? intval($params['workflow_id']) : 0; + // If From is not provided in the callback, try to get it from the call log + if (empty($from) && !empty($call_sid)) { + global $wpdb; + $call_log_table = $wpdb->prefix . 'twp_call_log'; + $call_record = $wpdb->get_row($wpdb->prepare( + "SELECT from_number FROM $call_log_table WHERE call_sid = %s LIMIT 1", + $call_sid + )); + if ($call_record && $call_record->from_number) { + $from = $call_record->from_number; + error_log('TWP Voicemail Callback: Retrieved from_number from call log: ' . $from); + } + } + + // Debug what we extracted + error_log('TWP Voicemail Callback: recording_url=' . $recording_url . ', from=' . $from . ', workflow_id=' . $workflow_id . ', call_sid=' . $call_sid); + if ($recording_url) { // Save voicemail record global $wpdb; @@ -807,7 +1088,7 @@ class TWP_Webhooks { // Update the agent call to join the same conference $twilio = new TWP_Twilio_API(); $agent_twiml = '' . $conference_name . ''; - $twilio->update_call($agent_call_sid, array('Twiml' => $agent_twiml)); + $twilio->update_call($agent_call_sid, array('twiml' => $agent_twiml)); // Mark callback as completed TWP_Callback_Manager::complete_callback($callback_id); @@ -892,9 +1173,11 @@ class TWP_Webhooks { private function notify_group_members_sms($group_id, $caller_number, $queue_name) { $members = TWP_Agent_Groups::get_group_members($group_id); $twilio = new TWP_Twilio_API(); - $sms_number = get_option('twp_sms_notification_number'); - if (empty($sms_number) || empty($members)) { + // Get SMS from number with proper priority (no workflow context here) + $from_number = TWP_Twilio_API::get_sms_from_number(); + + if (empty($from_number) || empty($members)) { return; } @@ -904,11 +1187,11 @@ class TWP_Webhooks { $agent_phone = get_user_meta($member->user_id, 'twp_phone_number', true); if (!empty($agent_phone)) { - // Send SMS notification - $twilio->send_sms($agent_phone, $message); + // Send SMS notification with proper from number + $twilio->send_sms($agent_phone, $message, $from_number); // Log the notification - error_log("TWP: SMS notification sent to agent {$member->user_id} at {$agent_phone}"); + error_log("TWP: SMS notification sent to agent {$member->user_id} at {$agent_phone} from {$from_number}"); } } } @@ -916,37 +1199,78 @@ class TWP_Webhooks { /** * Handle agent ready SMS (when agent texts "1") */ - private function handle_agent_ready_sms($agent_phone) { - // Find user by phone number + private function handle_agent_ready_sms($incoming_number) { + error_log('TWP Agent Ready: Processing agent ready SMS from incoming_number: ' . $incoming_number); + + // Standardized naming: incoming_number = phone number that sent the SMS to us + + // Normalize phone number - add + prefix if missing + $agent_number = $incoming_number; + if (!empty($incoming_number) && substr($incoming_number, 0, 1) !== '+') { + $agent_number = '+' . $incoming_number; + error_log('TWP Agent Ready: Normalized agent_number to ' . $agent_number); + } + + // Validate that this looks like a real phone number before proceeding + if (!preg_match('/^\+1[0-9]{10}$/', $agent_number)) { + error_log('TWP Agent Ready: Invalid phone number format: ' . $agent_number . ' - skipping error message send'); + return; // Don't send error messages to invalid numbers + } + + // Find user by phone number - try both original and normalized versions $users = get_users(array( 'meta_key' => 'twp_phone_number', - 'meta_value' => $agent_phone, + 'meta_value' => $agent_number, 'meta_compare' => '=' )); + // If not found with normalized number, try original if (empty($users)) { - // Send error message if agent not found - $twilio = new TWP_Twilio_API(); - $twilio->send_sms($agent_phone, "Phone number not found in system. Please contact administrator."); + $users = get_users(array( + 'meta_key' => 'twp_phone_number', + 'meta_value' => $incoming_number, + 'meta_compare' => '=' + )); + error_log('TWP Agent Ready: Tried original phone format: ' . $incoming_number); + } + + if (empty($users)) { + error_log('TWP Agent Ready: No user found for agent_number ' . $agent_number); + + // Only send error message to valid real phone numbers (not test numbers) + if (preg_match('/^\+1[0-9]{10}$/', $agent_number) && !preg_match('/^\+1951234567[0-9]$/', $agent_number)) { + $twilio = new TWP_Twilio_API(); + + // Get default Twilio number for sending error messages + $default_number = TWP_Twilio_API::get_sms_from_number(); + + $twilio->send_sms($agent_number, "Phone number not found in system. Please contact administrator.", $default_number); + } return; } $user = $users[0]; $user_id = $user->ID; + error_log('TWP Agent Ready: Found user ID ' . $user_id . ' for agent_number ' . $agent_number); + // Set agent status to available TWP_Agent_Manager::set_agent_status($user_id, 'available'); // Check for waiting calls and assign one if available - $assigned_call = $this->try_assign_call_to_agent($user_id, $agent_phone); + error_log('TWP Agent Ready: Calling try_assign_call_to_agent for user ' . $user_id); + $assigned_call = $this->try_assign_call_to_agent($user_id, $agent_number); + + $twilio = new TWP_Twilio_API(); + + // Get default Twilio number for sending confirmation messages + $default_number = TWP_Twilio_API::get_sms_from_number(); if ($assigned_call) { - $twilio = new TWP_Twilio_API(); - $twilio->send_sms($agent_phone, "Call assigned! You should receive the call shortly."); + $twilio->send_sms($agent_number, "Call assigned! You should receive the call shortly.", $default_number); } else { // No waiting calls, just confirm availability - $twilio = new TWP_Twilio_API(); - $twilio->send_sms($agent_phone, "Status updated to available. You'll receive the next waiting call."); + $twilio->send_sms($agent_number, "Status updated to available. You'll receive the next waiting call.", $default_number); } } @@ -956,82 +1280,189 @@ class TWP_Webhooks { public function handle_agent_connect($request) { $params = $request->get_params(); $queued_call_id = isset($params['queued_call_id']) ? intval($params['queued_call_id']) : 0; + $customer_call_sid = isset($params['customer_call_sid']) ? $params['customer_call_sid'] : ''; $customer_number = isset($params['customer_number']) ? $params['customer_number'] : ''; $agent_call_sid = isset($params['CallSid']) ? $params['CallSid'] : ''; + $agent_phone = isset($params['agent_phone']) ? $params['agent_phone'] : ''; - if (!$queued_call_id || !$customer_number) { + error_log('TWP Agent Connect: Handling agent connect - queued_call_id=' . $queued_call_id . ', customer_call_sid=' . $customer_call_sid . ', agent_call_sid=' . $agent_call_sid); + + if (!$queued_call_id && !$customer_call_sid) { + error_log('TWP Agent Connect: Missing required parameters'); $twiml = 'Unable to connect call.'; return $this->send_twiml_response($twiml); } - // Create conference to connect agent and customer - $conference_name = 'queue-connect-' . $queued_call_id . '-' . time(); - - $twiml = ''; - $twiml .= 'Connecting you to the customer now.'; - $twiml .= '' . $conference_name . ''; - $twiml .= ''; - - // Get the customer's call and redirect to conference + // Get the queued call from database global $wpdb; $calls_table = $wpdb->prefix . 'twp_queued_calls'; - $queued_call = $wpdb->get_row($wpdb->prepare( - "SELECT * FROM $calls_table WHERE id = %d", - $queued_call_id - )); - - if ($queued_call) { - // Connect customer to the same conference - $customer_twiml = ''; - $customer_twiml .= 'An agent is now available. Connecting you now.'; - $customer_twiml .= '' . $conference_name . ''; - $customer_twiml .= ''; - - $twilio = new TWP_Twilio_API(); - $twilio->update_call($queued_call->call_sid, array('Twiml' => $customer_twiml)); - - // Update call status to connected - $wpdb->update( - $calls_table, - array('status' => 'connected'), - array('id' => $queued_call_id), - array('%s'), - array('%d') - ); + if ($queued_call_id) { + $queued_call = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $calls_table WHERE id = %d", + $queued_call_id + )); + } else { + $queued_call = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $calls_table WHERE call_sid = %s AND status IN ('waiting', 'connecting')", + $customer_call_sid + )); } + if (!$queued_call) { + error_log('TWP Agent Connect: Queued call not found'); + $twiml = 'The customer call is no longer available.'; + return $this->send_twiml_response($twiml); + } + + // Create conference to connect agent and customer + $conference_name = 'queue-connect-' . $queued_call->id . '-' . time(); + + error_log('TWP Agent Connect: Creating conference ' . $conference_name); + + // Agent TwiML - connect agent to conference + $twiml = ''; + $twiml .= 'Connecting you to the customer now.'; + $twiml .= ''; + $twiml .= '' . $conference_name . ''; + $twiml .= ''; + $twiml .= ''; + + // Connect customer to the same conference + $customer_twiml = ''; + $customer_twiml .= 'An agent is now available. Connecting you now.'; + $customer_twiml .= ''; + $customer_twiml .= '' . $conference_name . ''; + $customer_twiml .= ''; + $customer_twiml .= 'The call has ended. Thank you.'; + $customer_twiml .= ''; + + try { + $twilio = new TWP_Twilio_API(); + $update_result = $twilio->update_call($queued_call->call_sid, array('twiml' => $customer_twiml)); + + if ($update_result['success']) { + error_log('TWP Agent Connect: Successfully updated customer call with conference TwiML'); + + // Update call status to connected/answered + $updated = $wpdb->update( + $calls_table, + array( + 'status' => 'answered', + 'agent_phone' => $agent_phone, + 'agent_call_sid' => $agent_call_sid, + 'answered_at' => current_time('mysql') + ), + array('id' => $queued_call->id), + array('%s', '%s', '%s', '%s'), + array('%d') + ); + + if ($updated) { + error_log('TWP Agent Connect: Updated call status to answered'); + } else { + error_log('TWP Agent Connect: Failed to update call status'); + } + + // Reorder queue positions after removing this call + TWP_Call_Queue::reorder_queue($queued_call->queue_id); + + } else { + error_log('TWP Agent Connect: Failed to update customer call: ' . ($update_result['error'] ?? 'Unknown error')); + } + + } catch (Exception $e) { + error_log('TWP Agent Connect: Exception updating customer call: ' . $e->getMessage()); + } + + error_log('TWP Agent Connect: Returning agent TwiML: ' . $twiml); return $this->send_twiml_response($twiml); } /** * Try to assign a waiting call to the agent */ - private function try_assign_call_to_agent($user_id, $agent_phone) { + private function try_assign_call_to_agent($user_id, $agent_number) { + error_log('TWP Call Assignment: Starting try_assign_call_to_agent for user ' . $user_id . ' agent_number ' . $agent_number); + global $wpdb; $calls_table = $wpdb->prefix . 'twp_queued_calls'; - // Find the longest waiting call - $waiting_call = $wpdb->get_row(" - SELECT * FROM $calls_table - WHERE status = 'waiting' - ORDER BY joined_at ASC - LIMIT 1 - "); + // Find the longest waiting call that this agent can handle + // Get agent's groups first + $groups_table = $wpdb->prefix . 'twp_group_members'; + $agent_groups = $wpdb->get_col($wpdb->prepare(" + SELECT group_id FROM $groups_table WHERE user_id = %d + ", $user_id)); + + $queues_table = $wpdb->prefix . 'twp_call_queues'; + + if (!empty($agent_groups)) { + // Find waiting calls from queues assigned to this agent's groups + $placeholders = implode(',', array_fill(0, count($agent_groups), '%d')); + $waiting_call = $wpdb->get_row($wpdb->prepare(" + SELECT qc.*, q.phone_number as queue_phone_number, q.agent_group_id + FROM $calls_table qc + LEFT JOIN $queues_table q ON qc.queue_id = q.id + WHERE qc.status = 'waiting' + AND (q.agent_group_id IN ($placeholders) OR q.agent_group_id IS NULL) + ORDER BY qc.joined_at ASC + LIMIT 1 + ", ...$agent_groups)); + } else { + // Agent not in any group - can only handle calls from queues with no assigned group + $waiting_call = $wpdb->get_row($wpdb->prepare(" + SELECT qc.*, q.phone_number as queue_phone_number, q.agent_group_id + FROM $calls_table qc + LEFT JOIN $queues_table q ON qc.queue_id = q.id + WHERE qc.status = %s + AND q.agent_group_id IS NULL + ORDER BY qc.joined_at ASC + LIMIT 1 + ", 'waiting')); + } if (!$waiting_call) { return false; } - // Make call to agent + // Determine which Twilio number to use as caller ID when calling the agent + // Priority: 1) Queue's workflow_number, 2) Original workflow_number, 3) default_number + $workflow_number = null; + + error_log('TWP Debug: Waiting call data: ' . print_r($waiting_call, true)); + + // Detailed debugging of phone number selection + error_log('TWP Debug: Queue workflow_number field: ' . (empty($waiting_call->queue_phone_number) ? 'EMPTY' : $waiting_call->queue_phone_number)); + error_log('TWP Debug: Original workflow_number field: ' . (empty($waiting_call->to_number) ? 'EMPTY' : $waiting_call->to_number)); + + if (!empty($waiting_call->queue_phone_number)) { + $workflow_number = $waiting_call->queue_phone_number; + error_log('TWP Debug: SELECTED queue workflow_number: ' . $workflow_number); + } elseif (!empty($waiting_call->to_number)) { + $workflow_number = $waiting_call->to_number; + error_log('TWP Debug: SELECTED original workflow_number: ' . $workflow_number); + } else { + $workflow_number = TWP_Twilio_API::get_sms_from_number(); + error_log('TWP Debug: SELECTED default_number: ' . $workflow_number); + } + + error_log('TWP Debug: Final workflow_number for agent call: ' . $workflow_number); + + // Make call to agent using the determined workflow number as caller ID $twilio = new TWP_Twilio_API(); + + // Build agent connect URL with parameters + $agent_connect_url = home_url('/wp-json/twilio-webhook/v1/agent-connect') . '?' . http_build_query(array( + 'queued_call_id' => $waiting_call->id, + 'customer_number' => $waiting_call->from_number + )); + $call_result = $twilio->make_call( - $agent_phone, - home_url('/wp-json/twilio-webhook/v1/agent-connect'), - array( - 'queued_call_id' => $waiting_call->id, - 'customer_number' => $waiting_call->from_number - ) + $agent_number, // To: agent's phone number + $agent_connect_url, // TwiML URL with parameters + null, // No status callback needed + $workflow_number // From: workflow number as caller ID ); if ($call_result['success']) { @@ -1056,6 +1487,131 @@ class TWP_Webhooks { return false; } + /** + * Handle agent screening - called when agent answers, before connecting to customer + */ + public function handle_agent_screen($request) { + $params = $request->get_params(); + $queued_call_id = isset($params['queued_call_id']) ? intval($params['queued_call_id']) : 0; + $customer_number = isset($params['customer_number']) ? $params['customer_number'] : ''; + $customer_call_sid = isset($params['customer_call_sid']) ? $params['customer_call_sid'] : ''; + $agent_call_sid = isset($params['CallSid']) ? $params['CallSid'] : ''; + + error_log("TWP Agent Screen: QueuedCallId={$queued_call_id}, CustomerCallSid={$customer_call_sid}, AgentCallSid={$agent_call_sid}"); + + if (!$queued_call_id || !$customer_call_sid) { + $twiml = 'Unable to connect call.'; + return $this->send_twiml_response($twiml); + } + + // Screen the agent - ask them to press a key to confirm they're human + $screen_url = home_url('/wp-json/twilio-webhook/v1/agent-confirm'); + $screen_url = add_query_arg(array( + 'queued_call_id' => $queued_call_id, + 'customer_call_sid' => $customer_call_sid, + 'agent_call_sid' => $agent_call_sid + ), $screen_url); + + // Use proper TwiML generation + $response = new \Twilio\TwiML\VoiceResponse(); + + $gather = $response->gather([ + 'timeout' => 10, + 'numDigits' => 1, + 'action' => $screen_url, + 'method' => 'POST' + ]); + $gather->say('You have an incoming call. Press any key to accept and connect to the caller.', ['voice' => 'alice']); + + $response->say('No response received. Call cancelled.', ['voice' => 'alice']); + $response->hangup(); + + return $this->send_twiml_response($response->asXML()); + } + + /** + * Handle agent confirmation - called when agent presses key to confirm + */ + public function handle_agent_confirm($request) { + $params = $request->get_params(); + $queued_call_id = isset($params['queued_call_id']) ? intval($params['queued_call_id']) : 0; + $customer_call_sid = isset($params['customer_call_sid']) ? $params['customer_call_sid'] : ''; + $agent_call_sid = isset($params['agent_call_sid']) ? $params['agent_call_sid'] : ''; + $digits = isset($params['Digits']) ? $params['Digits'] : ''; + + error_log("TWP Agent Confirm: Digits={$digits}, QueuedCallId={$queued_call_id}, CustomerCallSid={$customer_call_sid}, AgentCallSid={$agent_call_sid}"); + + if (!$digits) { + // No key pressed - agent didn't confirm + error_log("TWP Agent Confirm: No key pressed, cancelling call"); + + // Requeue the call for another agent + $this->handle_agent_no_answer($queued_call_id, 0, 'no_response'); + + $response = new \Twilio\TwiML\VoiceResponse(); + $response->say('Call cancelled.', ['voice' => 'alice']); + $response->hangup(); + return $this->send_twiml_response($response->asXML()); + } + + // Agent confirmed - now connect both calls to a conference + $conference_name = 'queue-connect-' . $queued_call_id . '-' . time(); + + error_log("TWP Agent Confirm: Creating conference {$conference_name} to connect customer and agent"); + + // Connect agent to conference using proper TwiML + $response = new \Twilio\TwiML\VoiceResponse(); + $response->say('Connecting you now.', ['voice' => 'alice']); + $dial = $response->dial(); + $dial->conference($conference_name); + + // Connect customer to the same conference + $this->connect_customer_to_conference($customer_call_sid, $conference_name, $queued_call_id); + + return $this->send_twiml_response($response->asXML()); + } + + /** + * Connect customer to conference + */ + private function connect_customer_to_conference($customer_call_sid, $conference_name, $queued_call_id) { + $twilio = new TWP_Twilio_API(); + + // Create TwiML to connect customer to conference using proper SDK + $customer_response = new \Twilio\TwiML\VoiceResponse(); + $customer_response->say('Connecting you to an agent.', ['voice' => 'alice']); + $customer_dial = $customer_response->dial(); + $customer_dial->conference($conference_name); + + $customer_twiml = $customer_response->asXML(); + + // Update the customer call to join the conference + $result = $twilio->update_call($customer_call_sid, array( + 'twiml' => $customer_twiml + )); + + if ($result['success']) { + // Update queued call status to connected + global $wpdb; + $calls_table = $wpdb->prefix . 'twp_queued_calls'; + + $wpdb->update( + $calls_table, + array( + 'status' => 'connected', + 'answered_at' => current_time('mysql') + ), + array('id' => $queued_call_id), + array('%s', '%s'), + array('%d') + ); + + error_log("TWP Agent Confirm: Successfully connected customer {$customer_call_sid} to conference {$conference_name}"); + } else { + error_log("TWP Agent Confirm: Failed to connect customer to conference: " . print_r($result, true)); + } + } + /** * Handle outbound agent with from number webhook */ @@ -1106,4 +1662,102 @@ class TWP_Webhooks { return $this->send_twiml_response($exception_response->asXML()); } } + + /** + * Handle agent call status to detect voicemail/no-answer + */ + public function handle_agent_call_status($request) { + $params = $request->get_params(); + + $call_status = isset($params['CallStatus']) ? $params['CallStatus'] : ''; + $call_sid = isset($params['CallSid']) ? $params['CallSid'] : ''; + $queued_call_id = isset($params['queued_call_id']) ? intval($params['queued_call_id']) : 0; + $user_id = isset($params['user_id']) ? intval($params['user_id']) : 0; + $original_call_sid = isset($params['original_call_sid']) ? $params['original_call_sid'] : ''; + + // Check for machine detection + $answered_by = isset($params['AnsweredBy']) ? $params['AnsweredBy'] : ''; + $machine_detection_duration = isset($params['MachineDetectionDuration']) ? $params['MachineDetectionDuration'] : ''; + + error_log("TWP Agent Call Status: CallSid={$call_sid}, Status={$call_status}, AnsweredBy={$answered_by}, QueuedCallId={$queued_call_id}"); + + // Handle different call statuses + switch ($call_status) { + case 'no-answer': + case 'busy': + case 'failed': + // Agent didn't answer or was busy - requeue the call or try next agent + $this->handle_agent_no_answer($queued_call_id, $user_id, $call_status); + break; + + case 'answered': + // Check if it was answered by a machine (voicemail) or human + if ($answered_by === 'machine') { + // Call went to voicemail - treat as no-answer + error_log("TWP Agent Call Status: Agent {$user_id} call went to voicemail - requeing"); + $this->handle_agent_no_answer($queued_call_id, $user_id, 'voicemail'); + } else { + // Agent actually answered - they're already set to busy + error_log("TWP Agent Call Status: Agent {$user_id} answered call {$call_sid}"); + } + break; + + case 'completed': + // Check if call was completed because it went to voicemail + if ($answered_by === 'machine_start' || $answered_by === 'machine_end_beep' || $answered_by === 'machine_end_silence') { + // Call went to voicemail - treat as no-answer and requeue + error_log("TWP Agent Call Status: Agent {$user_id} call completed via voicemail ({$answered_by}) - requeuing"); + $this->handle_agent_no_answer($queued_call_id, $user_id, 'voicemail'); + } else { + // Call completed normally - set agent back to available + TWP_Agent_Manager::set_agent_status($user_id, 'available'); + error_log("TWP Agent Call Status: Agent {$user_id} call completed normally, set to available"); + } + break; + } + + // Return empty response + return $this->send_twiml_response(''); + } + + /** + * Handle when agent doesn't answer (voicemail, busy, no-answer) + */ + private function handle_agent_no_answer($queued_call_id, $user_id, $status) { + error_log("TWP Agent No Answer: QueuedCallId={$queued_call_id}, UserId={$user_id}, Status={$status}"); + + global $wpdb; + $calls_table = $wpdb->prefix . 'twp_queued_calls'; + + // Get the queued call info + $queued_call = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $calls_table WHERE id = %d", + $queued_call_id + )); + + if (!$queued_call) { + error_log("TWP Agent No Answer: Queued call not found for ID {$queued_call_id}"); + return; + } + + // Set agent back to available + TWP_Agent_Manager::set_agent_status($user_id, 'available'); + + // Put the call back in waiting status for other agents to pick up + $wpdb->update( + $calls_table, + array( + 'status' => 'waiting', + 'answered_at' => null + ), + array('id' => $queued_call_id), + array('%s', '%s'), + array('%d') + ); + + error_log("TWP Agent No Answer: Call {$queued_call_id} returned to queue, agent {$user_id} set to available"); + + // Optionally: Try to assign to another available agent + // $this->try_assign_to_next_agent($queued_call->queue_id, $queued_call_id); + } } \ No newline at end of file diff --git a/includes/class-twp-workflow.php b/includes/class-twp-workflow.php index 240fc67..9194989 100644 --- a/includes/class-twp-workflow.php +++ b/includes/class-twp-workflow.php @@ -43,35 +43,78 @@ class TWP_Workflow { $twilio = new TWP_Twilio_API(); $elevenlabs = new TWP_ElevenLabs_API(); + // Store call data globally for access in step functions + $GLOBALS['call_data'] = $call_data; + + // Initialize combined TwiML response + $response = new \Twilio\TwiML\VoiceResponse(); + $has_response = false; + + error_log('TWP Workflow: Starting execution for workflow ID: ' . $workflow_id); + error_log('TWP Workflow: Call data: ' . json_encode($call_data)); + error_log('TWP Workflow: Steps count: ' . count($workflow_data['steps'])); + // Process workflow steps foreach ($workflow_data['steps'] as $step) { + // Check conditions first + if (isset($step['conditions'])) { + if (!self::check_conditions($step['conditions'], $call_data)) { + continue; + } + } + + $step_twiml = null; + $stop_after_step = false; + switch ($step['type']) { case 'greeting': - $twiml = self::create_greeting_twiml($step, $elevenlabs); + $step_twiml = self::create_greeting_twiml($step, $elevenlabs); break; case 'ivr_menu': - $twiml = self::create_ivr_menu_twiml($step, $elevenlabs); + $step_twiml = self::create_ivr_menu_twiml($step, $elevenlabs); + $stop_after_step = true; // IVR menu needs user input, stop here break; case 'forward': - $twiml = self::create_forward_twiml($step); + $step_twiml = self::create_forward_twiml($step); + $stop_after_step = true; // Forward ends the workflow break; case 'queue': - $twiml = self::create_queue_twiml($step); + error_log('TWP Workflow: Processing queue step: ' . json_encode($step)); + $step_twiml = self::create_queue_twiml($step, $elevenlabs); + $stop_after_step = true; // Queue ends the workflow break; case 'ring_group': - $twiml = self::create_ring_group_twiml($step); + $step_twiml = self::create_ring_group_twiml($step); + $stop_after_step = true; // Ring group ends the workflow break; case 'voicemail': - $twiml = self::create_voicemail_twiml($step, $elevenlabs); + // Add workflow_id to the step data + $step['workflow_id'] = $workflow_id; + $step_twiml = self::create_voicemail_twiml($step, $elevenlabs); + $stop_after_step = true; // Voicemail recording ends the workflow break; case 'schedule_check': - $twiml = self::handle_schedule_check($step, $call_data); + $schedule_result = self::handle_schedule_check($step, $call_data); + if ($schedule_result === false) { + // Continue to next step (within business hours) + error_log('TWP Schedule Check: Within business hours, continuing to next step'); + continue 2; + } elseif ($schedule_result) { + // After-hours steps returned TwiML - execute and stop + error_log('TWP Schedule Check: After hours, executing after-hours steps'); + $step_twiml = $schedule_result; + $stop_after_step = true; + } else { + // No schedule or no after-hours steps - continue with next step + error_log('TWP Schedule Check: No schedule configured or no after-hours steps, continuing'); + continue 2; + } break; case 'sms': @@ -82,42 +125,155 @@ class TWP_Workflow { continue 2; } - // Check conditions - if (isset($step['conditions'])) { - if (!self::check_conditions($step['conditions'], $call_data)) { - continue; + // Add step TwiML to combined response + if ($step_twiml) { + // Parse the step TwiML and append to combined response + $step_xml = simplexml_load_string($step_twiml); + if ($step_xml) { + foreach ($step_xml->children() as $element) { + self::append_twiml_element($response, $element); + } + $has_response = true; + } + + // Stop processing if this step type should end the workflow + if ($stop_after_step) { + break; } } - - // Execute step - if ($twiml) { - return $twiml; - } + } + + // Return combined response or default + if ($has_response) { + return $response->asXML(); } // Default response return self::create_default_response(); } + /** + * Helper function to append SimpleXMLElement to TwiML Response + */ + private static function append_twiml_element($response, $element) { + $name = $element->getName(); + $text = (string) $element; + $attributes = array(); + + foreach ($element->attributes() as $key => $value) { + $attributes[$key] = (string) $value; + } + + // Handle different TwiML verbs + switch ($name) { + case 'Say': + $response->say($text, $attributes); + break; + case 'Play': + $response->play($text, $attributes); + break; + case 'Gather': + $gather = $response->gather($attributes); + // Add child elements to gather + foreach ($element->children() as $child) { + $child_name = $child->getName(); + if ($child_name === 'Say') { + $gather->say((string) $child, self::get_attributes($child)); + } elseif ($child_name === 'Play') { + $gather->play((string) $child, self::get_attributes($child)); + } + } + break; + case 'Record': + $response->record($attributes); + break; + case 'Dial': + $response->dial((string) $element, $attributes); + break; + case 'Queue': + $response->queue((string) $element, $attributes); + break; + case 'Redirect': + $response->redirect((string) $element, $attributes); + break; + case 'Pause': + $response->pause($attributes); + break; + case 'Hangup': + $response->hangup(); + break; + } + } + + /** + * Helper to get attributes as array + */ + private static function get_attributes($element) { + $attributes = array(); + foreach ($element->attributes() as $key => $value) { + $attributes[$key] = (string) $value; + } + return $attributes; + } + /** * Create greeting TwiML */ private static function create_greeting_twiml($step, $elevenlabs) { $twiml = new SimpleXMLElement(''); - if (isset($step['use_tts']) && $step['use_tts']) { - // Generate TTS audio - $audio_result = $elevenlabs->text_to_speech($step['message']); - - if ($audio_result['success']) { - $play = $twiml->addChild('Play', $audio_result['file_url']); - } else { - $say = $twiml->addChild('Say', $step['message']); - $say->addAttribute('voice', 'alice'); - } + // Get message from either data array or direct property + $message = null; + if (isset($step['data']['message']) && !empty($step['data']['message'])) { + $message = $step['data']['message']; + } elseif (isset($step['message']) && !empty($step['message'])) { + $message = $step['message']; } else { - $say = $twiml->addChild('Say', $step['message']); - $say->addAttribute('voice', 'alice'); + $message = 'Welcome to our phone system.'; + } + + // Check for new audio_type structure or legacy use_tts + $audio_type = isset($step['data']['audio_type']) ? $step['data']['audio_type'] : + (isset($step['audio_type']) ? $step['audio_type'] : + (isset($step['data']['use_tts']) && $step['data']['use_tts'] ? 'tts' : + (isset($step['use_tts']) && $step['use_tts'] ? 'tts' : 'say'))); + + switch ($audio_type) { + case 'tts': + // Generate TTS audio + $voice_id = isset($step['data']['voice_id']) ? $step['data']['voice_id'] : + (isset($step['voice_id']) ? $step['voice_id'] : null); + $audio_result = $elevenlabs->text_to_speech($message, [ + 'voice_id' => $voice_id + ]); + + if ($audio_result['success']) { + $play = $twiml->addChild('Play', $audio_result['file_url']); + } else { + // Fallback to Say + $say = $twiml->addChild('Say', $message); + $say->addAttribute('voice', 'alice'); + } + break; + + case 'audio': + // Use provided audio file + $audio_url = isset($step['data']['audio_url']) ? $step['data']['audio_url'] : + (isset($step['audio_url']) ? $step['audio_url'] : null); + + if ($audio_url && !empty($audio_url)) { + $play = $twiml->addChild('Play', $audio_url); + } else { + // Fallback to Say if no audio URL provided + $say = $twiml->addChild('Say', $message); + $say->addAttribute('voice', 'alice'); + } + break; + + default: // 'say' or fallback + $say = $twiml->addChild('Say', $message); + $say->addAttribute('voice', 'alice'); + break; } return $twiml->asXML(); @@ -130,31 +286,72 @@ class TWP_Workflow { $twiml = new SimpleXMLElement(''); $gather = $twiml->addChild('Gather'); - $gather->addAttribute('numDigits', isset($step['num_digits']) ? $step['num_digits'] : '1'); - $gather->addAttribute('timeout', isset($step['timeout']) ? $step['timeout'] : '10'); + $gather->addAttribute('numDigits', isset($step['data']['num_digits']) ? $step['data']['num_digits'] : + (isset($step['num_digits']) ? $step['num_digits'] : '1')); + $gather->addAttribute('timeout', isset($step['data']['timeout']) ? $step['data']['timeout'] : + (isset($step['timeout']) ? $step['timeout'] : '10')); if (isset($step['action_url'])) { $gather->addAttribute('action', $step['action_url']); } else { - $webhook_url = home_url('/twilio-webhook/ivr-response'); + $webhook_url = home_url('/wp-json/twilio-webhook/v1/ivr-response'); $webhook_url = add_query_arg('workflow_id', $step['workflow_id'], $webhook_url); $webhook_url = add_query_arg('step_id', $step['id'], $webhook_url); $gather->addAttribute('action', $webhook_url); } - if (isset($step['use_tts']) && $step['use_tts']) { - // Generate TTS for menu options - $audio_result = $elevenlabs->text_to_speech($step['message']); - - if ($audio_result['success']) { - $play = $gather->addChild('Play', $audio_result['file_url']); - } else { - $say = $gather->addChild('Say', $step['message']); - $say->addAttribute('voice', 'alice'); - } + // Get message from either data array or direct property + $message = null; + if (isset($step['data']['message']) && !empty($step['data']['message'])) { + $message = $step['data']['message']; + } elseif (isset($step['message']) && !empty($step['message'])) { + $message = $step['message']; } else { - $say = $gather->addChild('Say', $step['message']); - $say->addAttribute('voice', 'alice'); + $message = 'Please select an option.'; + } + + // Check for new audio_type structure or legacy use_tts + $audio_type = isset($step['data']['audio_type']) ? $step['data']['audio_type'] : + (isset($step['audio_type']) ? $step['audio_type'] : + (isset($step['data']['use_tts']) && $step['data']['use_tts'] ? 'tts' : + (isset($step['use_tts']) && $step['use_tts'] ? 'tts' : 'say'))); + + switch ($audio_type) { + case 'tts': + // Generate TTS audio + $voice_id = isset($step['data']['voice_id']) ? $step['data']['voice_id'] : + (isset($step['voice_id']) ? $step['voice_id'] : null); + $audio_result = $elevenlabs->text_to_speech($message, [ + 'voice_id' => $voice_id + ]); + + if ($audio_result['success']) { + $play = $gather->addChild('Play', $audio_result['file_url']); + } else { + // Fallback to Say + $say = $gather->addChild('Say', $message); + $say->addAttribute('voice', 'alice'); + } + break; + + case 'audio': + // Use provided audio file + $audio_url = isset($step['data']['audio_url']) ? $step['data']['audio_url'] : + (isset($step['audio_url']) ? $step['audio_url'] : null); + + if ($audio_url && !empty($audio_url)) { + $play = $gather->addChild('Play', $audio_url); + } else { + // Fallback to Say if no audio URL provided + $say = $gather->addChild('Say', $message); + $say->addAttribute('voice', 'alice'); + } + break; + + default: // 'say' or fallback + $say = $gather->addChild('Say', $message); + $say->addAttribute('voice', 'alice'); + break; } // Fallback if no input @@ -173,6 +370,7 @@ class TWP_Workflow { case 'forward': if (isset($step['forward_number'])) { $dial = $twiml->addChild('Dial'); + $dial->addAttribute('answerOnBridge', 'true'); $dial->addChild('Number', $step['forward_number']); } break; @@ -189,6 +387,7 @@ class TWP_Workflow { $twiml = new SimpleXMLElement(''); $dial = $twiml->addChild('Dial'); + $dial->addAttribute('answerOnBridge', 'true'); if (isset($step['timeout'])) { $dial->addAttribute('timeout', $step['timeout']); @@ -209,25 +408,141 @@ class TWP_Workflow { /** * Create queue TwiML */ - private static function create_queue_twiml($step) { + private static function create_queue_twiml($step, $elevenlabs) { + error_log('TWP Workflow: Creating queue TwiML with step data: ' . print_r($step, true)); + $twiml = new SimpleXMLElement(''); - if (isset($step['announce_message'])) { - $say = $twiml->addChild('Say', $step['announce_message']); - $say->addAttribute('voice', 'alice'); - } + // Check if data is nested (workflow steps have data nested) + $step_data = isset($step['data']) ? $step['data'] : $step; - $enqueue = $twiml->addChild('Enqueue', $step['queue_name']); + // Get the actual queue name from the database if we have a queue_id + $queue_name = ''; + $queue_id = null; - if (isset($step['wait_url'])) { - $enqueue->addAttribute('waitUrl', $step['wait_url']); + if (isset($step_data['queue_id']) && !empty($step_data['queue_id'])) { + $queue_id = $step_data['queue_id']; + error_log('TWP Workflow: Looking up queue with ID: ' . $queue_id); + $queue = TWP_Call_Queue::get_queue($queue_id); + if ($queue) { + $queue_name = $queue->queue_name; + error_log('TWP Workflow: Found queue name: ' . $queue_name); + } else { + error_log('TWP Workflow: Queue not found in database for ID: ' . $queue_id); + } + } elseif (isset($step_data['queue_name']) && !empty($step_data['queue_name'])) { + // Fallback to queue_name if provided directly + $queue_name = $step_data['queue_name']; + error_log('TWP Workflow: Using queue_name directly: ' . $queue_name); } else { - $wait_url = home_url('/twilio-webhook/queue-wait'); - $wait_url = add_query_arg('queue_id', $step['queue_id'], $wait_url); - $enqueue->addAttribute('waitUrl', $wait_url); + error_log('TWP Workflow: No queue_id or queue_name in step data'); } - return $twiml->asXML(); + // Log error if no queue name found + if (empty($queue_name)) { + error_log('TWP Workflow: ERROR - Queue name is empty after lookup'); + // Return error message instead of empty queue + $say = $twiml->addChild('Say', 'Sorry, the queue is not configured properly. Please try again later.'); + $say->addAttribute('voice', 'alice'); + return $twiml->asXML(); + } + + error_log('TWP Workflow: Using queue name for Enqueue: ' . $queue_name); + + // Add call to queue database BEFORE generating Enqueue TwiML + // Get call info from current request context or call_data parameter + $call_sid = isset($_POST['CallSid']) ? $_POST['CallSid'] : + (isset($_REQUEST['CallSid']) ? $_REQUEST['CallSid'] : + (isset($GLOBALS['call_data']['CallSid']) ? $GLOBALS['call_data']['CallSid'] : '')); + $from_number = isset($_POST['From']) ? $_POST['From'] : + (isset($_REQUEST['From']) ? $_REQUEST['From'] : + (isset($GLOBALS['call_data']['From']) ? $GLOBALS['call_data']['From'] : '')); + $to_number = isset($_POST['To']) ? $_POST['To'] : + (isset($_REQUEST['To']) ? $_REQUEST['To'] : + (isset($GLOBALS['call_data']['To']) ? $GLOBALS['call_data']['To'] : '')); + + error_log('TWP Queue: Call data - SID: ' . $call_sid . ', From: ' . $from_number . ', To: ' . $to_number); + + if ($call_sid && $queue_id) { + error_log('TWP Workflow: Adding call to queue database - CallSid: ' . $call_sid . ', Queue ID: ' . $queue_id); + $add_result = TWP_Call_Queue::add_to_queue($queue_id, array( + 'call_sid' => $call_sid, + 'from_number' => $from_number, + 'to_number' => $to_number + )); + error_log('TWP Workflow: Add to queue result: ' . ($add_result ? 'success' : 'failed')); + } else { + error_log('TWP Workflow: Cannot add to queue - missing CallSid (' . $call_sid . ') or queue_id (' . $queue_id . ')'); + } + + // Instead of using Twilio's Enqueue, redirect to our queue wait handler + // This gives us complete control over the queue experience + + // Get announcement message + $message = ''; + if (isset($step_data['announce_message']) && !empty($step_data['announce_message'])) { + $message = $step_data['announce_message']; + } else { + $message = 'Please hold while we connect you to the next available agent.'; + } + + // Handle audio type for queue announcement (same logic as other steps) + $audio_type = isset($step_data['audio_type']) ? $step_data['audio_type'] : 'say'; + + switch ($audio_type) { + case 'tts': + // Generate TTS audio + $voice_id = isset($step_data['voice_id']) ? $step_data['voice_id'] : null; + $audio_result = $elevenlabs->text_to_speech($message, [ + 'voice_id' => $voice_id + ]); + + if ($audio_result['success']) { + $play = $twiml->addChild('Play', $audio_result['file_url']); + } else { + // Fallback to Say + $say = $twiml->addChild('Say', $message); + $say->addAttribute('voice', 'alice'); + } + break; + + case 'audio': + // Use provided audio file + $audio_url = isset($step_data['audio_url']) ? $step_data['audio_url'] : null; + + if ($audio_url && !empty($audio_url)) { + $play = $twiml->addChild('Play', $audio_url); + } else { + // Fallback to Say if no audio URL provided + $say = $twiml->addChild('Say', $message); + $say->addAttribute('voice', 'alice'); + } + break; + + default: // 'say' + $say = $twiml->addChild('Say', $message); + $say->addAttribute('voice', 'alice'); + break; + } + + // Build the redirect URL properly + $wait_url = home_url('/wp-json/twilio-webhook/v1/queue-wait'); + $wait_url = add_query_arg(array( + 'queue_id' => $queue_id, + 'call_sid' => urlencode($call_sid) // URL encode to handle special characters + ), $wait_url); + + // Set the text content of Redirect element properly + $redirect = $twiml->addChild('Redirect'); + $redirect[0] = $wait_url; // Set the URL as the text content + $redirect->addAttribute('method', 'POST'); + + error_log('TWP Workflow: Redirecting to custom queue wait handler: ' . $wait_url); + + $result = $twiml->asXML(); + error_log('TWP Workflow: Final Queue TwiML: ' . $result); + + return $result; } /** @@ -253,6 +568,7 @@ class TWP_Workflow { } $dial = $twiml->addChild('Dial'); + $dial->addAttribute('answerOnBridge', 'true'); if (isset($step['timeout'])) { $dial->addAttribute('timeout', $step['timeout']); @@ -288,33 +604,125 @@ class TWP_Workflow { private static function create_voicemail_twiml($step, $elevenlabs) { $twiml = new SimpleXMLElement(''); - if (isset($step['greeting_message'])) { - if (isset($step['use_tts']) && $step['use_tts']) { - $audio_result = $elevenlabs->text_to_speech($step['greeting_message']); - - if ($audio_result['success']) { - $play = $twiml->addChild('Play', $audio_result['file_url']); - } else { - $say = $twiml->addChild('Say', $step['greeting_message']); + // Debug logging + error_log('TWP Voicemail Step Data: ' . json_encode($step)); + + // Check for greeting message in different possible field names + // The step data might be nested in a 'data' object + $greeting = null; + if (isset($step['data']['greeting_message']) && !empty($step['data']['greeting_message'])) { + $greeting = $step['data']['greeting_message']; + error_log('TWP Voicemail: Using data.greeting_message: ' . $greeting); + } elseif (isset($step['greeting_message']) && !empty($step['greeting_message'])) { + $greeting = $step['greeting_message']; + error_log('TWP Voicemail: Using greeting_message: ' . $greeting); + } elseif (isset($step['data']['message']) && !empty($step['data']['message'])) { + $greeting = $step['data']['message']; + error_log('TWP Voicemail: Using data.message: ' . $greeting); + } elseif (isset($step['message']) && !empty($step['message'])) { + $greeting = $step['message']; + error_log('TWP Voicemail: Using message: ' . $greeting); + } elseif (isset($step['data']['prompt']) && !empty($step['data']['prompt'])) { + $greeting = $step['data']['prompt']; + error_log('TWP Voicemail: Using data.prompt: ' . $greeting); + } elseif (isset($step['prompt']) && !empty($step['prompt'])) { + $greeting = $step['prompt']; + error_log('TWP Voicemail: Using prompt: ' . $greeting); + } elseif (isset($step['data']['text']) && !empty($step['data']['text'])) { + $greeting = $step['data']['text']; + error_log('TWP Voicemail: Using data.text: ' . $greeting); + } elseif (isset($step['text']) && !empty($step['text'])) { + $greeting = $step['text']; + error_log('TWP Voicemail: Using text: ' . $greeting); + } + + // Add greeting message if provided + if ($greeting) { + error_log('TWP Voicemail: Found greeting: ' . $greeting); + + // Check for new audio_type structure or legacy use_tts + $audio_type = isset($step['data']['audio_type']) ? $step['data']['audio_type'] : + (isset($step['audio_type']) ? $step['audio_type'] : + (isset($step['data']['use_tts']) && $step['data']['use_tts'] ? 'tts' : + (isset($step['use_tts']) && $step['use_tts'] ? 'tts' : 'say'))); + error_log('TWP Voicemail: audio_type = ' . $audio_type); + + switch ($audio_type) { + case 'tts': + error_log('TWP Voicemail: Attempting ElevenLabs TTS'); + // Check for voice_id in data object or root + $voice_id = isset($step['data']['voice_id']) && !empty($step['data']['voice_id']) ? $step['data']['voice_id'] : + (isset($step['voice_id']) && !empty($step['voice_id']) ? $step['voice_id'] : null); + error_log('TWP Voicemail: voice_id = ' . ($voice_id ?: 'default')); + $audio_result = $elevenlabs->text_to_speech($greeting, [ + 'voice_id' => $voice_id + ]); + + if ($audio_result && isset($audio_result['success']) && $audio_result['success']) { + error_log('TWP Voicemail: ElevenLabs TTS successful, using audio file: ' . $audio_result['file_url']); + $play = $twiml->addChild('Play', $audio_result['file_url']); + } else { + error_log('TWP Voicemail: ElevenLabs TTS failed, falling back to Say: ' . json_encode($audio_result)); + $say = $twiml->addChild('Say', $greeting); + $say->addAttribute('voice', 'alice'); + } + break; + + case 'audio': + // Use provided audio file + $audio_url = isset($step['data']['audio_url']) ? $step['data']['audio_url'] : + (isset($step['audio_url']) ? $step['audio_url'] : null); + + if ($audio_url && !empty($audio_url)) { + error_log('TWP Voicemail: Using audio file: ' . $audio_url); + $play = $twiml->addChild('Play', $audio_url); + } else { + error_log('TWP Voicemail: No audio URL provided, falling back to Say'); + $say = $twiml->addChild('Say', $greeting); + $say->addAttribute('voice', 'alice'); + } + break; + + default: // 'say' or fallback + error_log('TWP Voicemail: Using standard Say for greeting'); + $say = $twiml->addChild('Say', $greeting); $say->addAttribute('voice', 'alice'); - } - } else { - $say = $twiml->addChild('Say', $step['greeting_message']); - $say->addAttribute('voice', 'alice'); + break; } + } else { + error_log('TWP Voicemail: No custom greeting found, using default'); + // Default greeting if none provided + $say = $twiml->addChild('Say', 'Please leave your message after the beep. Press the pound key when finished.'); + $say->addAttribute('voice', 'alice'); } $record = $twiml->addChild('Record'); $record->addAttribute('maxLength', isset($step['max_length']) ? $step['max_length'] : '120'); $record->addAttribute('playBeep', 'true'); - $record->addAttribute('transcribe', 'true'); - $record->addAttribute('transcribeCallback', home_url('/wp-json/twilio-webhook/v1/transcription')); + $record->addAttribute('finishOnKey', '#'); + $record->addAttribute('timeout', '10'); + // Add action URL to handle what happens after recording + $action_url = home_url('/wp-json/twilio-webhook/v1/voicemail-complete'); + $record->addAttribute('action', $action_url); + + // Add recording status callback for saving the voicemail $callback_url = home_url('/wp-json/twilio-webhook/v1/voicemail-callback'); - $callback_url = add_query_arg('workflow_id', $step['workflow_id'], $callback_url); + if (isset($step['workflow_id'])) { + $callback_url = add_query_arg('workflow_id', $step['workflow_id'], $callback_url); + } $record->addAttribute('recordingStatusCallback', $callback_url); + $record->addAttribute('recordingStatusCallbackMethod', 'POST'); - return $twiml->asXML(); + // Add transcription (enabled by default unless explicitly disabled) + if (!isset($step['transcribe']) || $step['transcribe'] !== false) { + $record->addAttribute('transcribe', 'true'); + $record->addAttribute('transcribeCallback', home_url('/wp-json/twilio-webhook/v1/transcription')); + } + + $twiml_output = $twiml->asXML(); + error_log('TWP Voicemail: Generated TwiML: ' . $twiml_output); + return $twiml_output; } /** @@ -323,44 +731,141 @@ class TWP_Workflow { private static function handle_schedule_check($step, $call_data) { $schedule_id = $step['data']['schedule_id'] ?? $step['schedule_id'] ?? null; + error_log('TWP Schedule Check: Processing schedule check with ID: ' . ($schedule_id ?: 'none')); + error_log('TWP Schedule Check: Step data: ' . json_encode($step)); + if (!$schedule_id) { + error_log('TWP Schedule Check: No schedule ID specified, continuing to next step'); // No schedule specified, return false to continue to next step return false; } - $routing = TWP_Scheduler::get_schedule_routing($schedule_id); + // Check if we're within business hours first + $is_active = TWP_Scheduler::is_schedule_active($schedule_id); + error_log('TWP Schedule Check: Schedule active status: ' . ($is_active ? 'true' : 'false')); - if ($routing['action'] === 'workflow' && $routing['data']['workflow_id']) { - // Route to different workflow - $workflow_id = $routing['data']['workflow_id']; - $workflow = self::get_workflow($workflow_id); - - if ($workflow && $workflow->is_active) { - return self::execute_workflow($workflow_id, $call_data); - } - } else if ($routing['action'] === 'forward' && $routing['data']['forward_number']) { - // Forward call - $twiml = new \Twilio\TwiML\VoiceResponse(); - $twiml->dial($routing['data']['forward_number']); - return $twiml->asXML(); - } - - // Fallback to legacy behavior if new routing doesn't work - if (TWP_Scheduler::is_schedule_active($schedule_id)) { - // Execute in-hours action - if (isset($step['in_hours_action'])) { - return self::execute_action($step['in_hours_action'], $call_data); - } + if ($is_active) { + error_log('TWP Schedule Check: Within business hours, continuing to next workflow step'); + // Within business hours - continue with normal workflow + return false; // Continue to next workflow step } else { - // Execute after-hours action - if (isset($step['after_hours_action'])) { - return self::execute_action($step['after_hours_action'], $call_data); + error_log('TWP Schedule Check: Outside business hours, checking for after-hours steps'); + // After hours - execute after-hours steps + $after_hours_steps = null; + + if (isset($step['data']['after_hours_steps']) && !empty($step['data']['after_hours_steps'])) { + $after_hours_steps = $step['data']['after_hours_steps']; + } elseif (isset($step['after_hours_steps']) && !empty($step['after_hours_steps'])) { + $after_hours_steps = $step['after_hours_steps']; + } + + if ($after_hours_steps) { + error_log('TWP Schedule Check: Found after-hours steps, executing: ' . json_encode($after_hours_steps)); + return self::execute_after_hours_steps($after_hours_steps, $call_data); + } else { + error_log('TWP Schedule Check: No after-hours steps configured'); + + // Fall back to schedule routing if no after-hours steps in workflow + $routing = TWP_Scheduler::get_schedule_routing($schedule_id); + + if ($routing['action'] === 'workflow' && $routing['data']['workflow_id']) { + error_log('TWP Schedule Check: Using schedule routing to workflow: ' . $routing['data']['workflow_id']); + // Route to different workflow + $workflow_id = $routing['data']['workflow_id']; + $workflow = self::get_workflow($workflow_id); + + if ($workflow && $workflow->is_active) { + return self::execute_workflow($workflow_id, $call_data); + } + } else if ($routing['action'] === 'forward' && $routing['data']['forward_number']) { + error_log('TWP Schedule Check: Using schedule routing to forward: ' . $routing['data']['forward_number']); + // Forward call + $twiml = new \Twilio\TwiML\VoiceResponse(); + $twiml->dial($routing['data']['forward_number']); + return $twiml->asXML(); + } } } + error_log('TWP Schedule Check: No action taken, continuing to next step'); return false; } + /** + * Execute after-hours steps + */ + private static function execute_after_hours_steps($steps, $call_data) { + $twiml = new SimpleXMLElement(''); + + foreach ($steps as $step) { + switch ($step['type']) { + case 'greeting': + if (isset($step['message']) && !empty($step['message'])) { + $say = $twiml->addChild('Say', $step['message']); + $say->addAttribute('voice', 'alice'); + } + break; + + case 'forward': + if (isset($step['number']) && !empty($step['number'])) { + $dial = $twiml->addChild('Dial'); + $dial->addAttribute('answerOnBridge', 'true'); + $dial->addChild('Number', $step['number']); + return $twiml->asXML(); // End here for forward + } + break; + + case 'voicemail': + // Add greeting if provided + if (isset($step['greeting']) && !empty($step['greeting'])) { + $say = $twiml->addChild('Say', $step['greeting']); + $say->addAttribute('voice', 'alice'); + } + + // Add record + $record = $twiml->addChild('Record'); + $record->addAttribute('maxLength', '120'); + $record->addAttribute('playBeep', 'true'); + $record->addAttribute('finishOnKey', '#'); + $record->addAttribute('timeout', '10'); + $record->addAttribute('action', home_url('/wp-json/twilio-webhook/v1/voicemail-complete')); + $record->addAttribute('recordingStatusCallback', home_url('/wp-json/twilio-webhook/v1/voicemail-callback')); + $record->addAttribute('recordingStatusCallbackMethod', 'POST'); + $record->addAttribute('transcribe', 'true'); + $record->addAttribute('transcribeCallback', home_url('/wp-json/twilio-webhook/v1/transcription')); + return $twiml->asXML(); // End here for voicemail + + case 'queue': + if (isset($step['queue_name']) && !empty($step['queue_name'])) { + $enqueue = $twiml->addChild('Enqueue', $step['queue_name']); + return $twiml->asXML(); // End here for queue + } + break; + + case 'sms': + if (isset($step['to_number']) && !empty($step['to_number']) && + isset($step['message']) && !empty($step['message'])) { + // Send SMS notification + $twilio = new TWP_Twilio_API(); + + // Get the from number using proper priority + $workflow_id = isset($step['workflow_id']) ? $step['workflow_id'] : null; + $from_number = TWP_Twilio_API::get_sms_from_number($workflow_id); + + $message = str_replace( + array('{from}', '{to}', '{time}'), + array($call_data['From'], $call_data['To'], current_time('g:i A')), + $step['message'] + ); + $twilio->send_sms($step['to_number'], $message, $from_number); + } + break; + } + } + + return $twiml->asXML(); + } + /** * Execute action */ @@ -427,13 +932,17 @@ class TWP_Workflow { private static function send_sms_notification($step, $call_data) { $twilio = new TWP_Twilio_API(); + // Get the from number - priority: workflow phone > default SMS number > first Twilio number + $workflow_id = isset($step['workflow_id']) ? $step['workflow_id'] : null; + $from_number = TWP_Twilio_API::get_sms_from_number($workflow_id); + $message = str_replace( array('{from}', '{to}', '{time}'), array($call_data['From'], $call_data['To'], current_time('g:i A')), $step['message'] ); - $twilio->send_sms($step['to_number'], $message); + $twilio->send_sms($step['to_number'], $message, $from_number); } /** @@ -492,7 +1001,12 @@ class TWP_Workflow { } if (isset($data['workflow_data'])) { - $update_data['workflow_data'] = json_encode($data['workflow_data']); + // Check if workflow_data is already JSON string or needs encoding + if (is_string($data['workflow_data'])) { + $update_data['workflow_data'] = $data['workflow_data']; + } else { + $update_data['workflow_data'] = json_encode($data['workflow_data']); + } $update_format[] = '%s'; } diff --git a/twilio-wp-plugin.php b/twilio-wp-plugin.php index b0a799e..7cde78e 100644 --- a/twilio-wp-plugin.php +++ b/twilio-wp-plugin.php @@ -15,7 +15,8 @@ if (!defined('WPINC')) { } // Plugin constants -define('TWP_VERSION', '1.0.0'); +define('TWP_VERSION', '1.3.13'); +define('TWP_DB_VERSION', '1.1.0'); // Track database version separately define('TWP_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('TWP_PLUGIN_URL', plugin_dir_url(__FILE__)); define('TWP_PLUGIN_BASENAME', plugin_basename(__FILE__)); @@ -62,9 +63,23 @@ function twp_sdk_missing_notice() {