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
+
+
+ Select a Twilio number...
+ get_phone_numbers();
+
+ if ($numbers_result['success'] && isset($numbers_result['data']['incoming_phone_numbers'])) {
+ $numbers = $numbers_result['data']['incoming_phone_numbers'];
+ if (is_array($numbers) && !empty($numbers)) {
+ foreach ($numbers as $number) {
+ $phone = isset($number['phone_number']) ? $number['phone_number'] : '';
+ $friendly_name = isset($number['friendly_name']) ? $number['friendly_name'] : $phone;
+ if (!empty($phone)) {
+ $selected = ($phone === $current_sms_number) ? ' selected' : '';
+ echo '' . esc_html($friendly_name . ' (' . $phone . ')') . ' ';
+ }
+ }
+ }
+ }
+ } catch (Exception $e) {
+ // If there's an error loading numbers, show the current value as a manual input
+ if (!empty($current_sms_number)) {
+ echo '' . esc_html($current_sms_number . ' (configured)') . ' ';
+ }
+ }
+ ?>
+
+ Refresh Numbers
+ 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...
+
+
+
+
+ Refresh List
+
+
+ Enable for All Numbers
+
+
+
+
+
+
';
break;
case 'sms':
@@ -378,23 +556,183 @@ jQuery(document).ready(function($) {
var html = '';
return html;
}
+ function generateAfterHoursStepHtml(step, index) {
+ var 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 = 'Select a schedule... ';
+
+ response.data.forEach(function(schedule) {
+ var selected = (schedule.id == currentScheduleId) ? ' selected' : '';
+ options += '' + schedule.schedule_name + ' ';
+ 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('Error loading schedules ');
+ }
+ }).fail(function(xhr, status, error) {
+ console.error('AJAX error loading schedules:', error);
+ $('#schedule-select').html('Failed to load schedules ');
+ });
+ };
+
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 += '
' + 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 = 'Select queue... ';
response.data.forEach(function(queue) {
- var selected = queue.queue_name === currentValue ? ' selected' : '';
- options += '' + queue.queue_name + ' ';
+ var selected = queue.id == currentValue ? ' selected' : '';
+ options += '' + queue.queue_name + ' ';
});
$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 = 'Select queue... ';
+
+ response.data.forEach(function(queue) {
+ var selected = queue.id == currentValue ? ' selected' : '';
+ options += '' + queue.queue_name + ' ';
+ });
+
+ $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 = `
+
+
+
+
+
โ
Call has been assigned to you successfully.
+
๐ฑ You should receive the call on your phone within the next few seconds.
+
๐ The call will connect you directly to the customer.
+
+
+
+
+ `;
+
+ // 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
+
+
+ Your browser does not support the audio element.
+
+ ` : ''}
+
+ `;
+
+ // 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() {