Files
twilio-wp-plugin/includes/class-twp-workflow.php
jknapp 0ee8210fef Fix caller ID for workflow forward and ring group tasks
Updated forward and ring group tasks to use the incoming number (To) as the caller ID for outbound calls instead of the original caller's number (From).

Changes:
- Forward task now sets callerId attribute to the number that was called
- Ring group task also defaults to using the incoming number as caller ID
- Both functions check multiple sources for the To number (GLOBALS, POST, REQUEST)
- Added detailed logging for caller ID selection

This ensures that when calls are forwarded, the receiving party sees the business number that was called, not the original caller's personal number.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 16:46:52 -07:00

1343 lines
58 KiB
PHP

<?php
/**
* Workflow management class
*/
class TWP_Workflow {
/**
* Create workflow
*/
public static function create_workflow($data) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_workflows';
$workflow_data = array(
'steps' => $data['steps'],
'conditions' => $data['conditions'],
'actions' => $data['actions']
);
return $wpdb->insert(
$table_name,
array(
'workflow_name' => sanitize_text_field($data['workflow_name']),
'phone_number' => sanitize_text_field($data['phone_number']),
'workflow_data' => json_encode($workflow_data),
'is_active' => isset($data['is_active']) ? 1 : 0
),
array('%s', '%s', '%s', '%d')
);
}
/**
* Execute workflow
*/
public static function execute_workflow($workflow_id, $call_data) {
$workflow = self::get_workflow($workflow_id);
if (!$workflow || !$workflow->is_active) {
return false;
}
$workflow_data = json_decode($workflow->workflow_data, true);
$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':
$step_twiml = self::create_greeting_twiml($step, $elevenlabs);
break;
case 'ivr_menu':
// Add workflow_id to the step data
$step['workflow_id'] = $workflow_id;
$step_twiml = self::create_ivr_menu_twiml($step, $elevenlabs);
$stop_after_step = true; // IVR menu needs user input, stop here
break;
case 'forward':
error_log('TWP Workflow: Processing forward step: ' . json_encode($step));
$step_twiml = self::create_forward_twiml($step);
error_log('TWP Workflow: Forward step TwiML generated: ' . $step_twiml);
$stop_after_step = true; // Forward ends the workflow
break;
case 'queue':
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 'browser_call':
error_log('TWP Workflow: Processing browser call step: ' . json_encode($step));
$step_twiml = self::create_browser_call_twiml($step, $elevenlabs);
$stop_after_step = true; // Browser call ends the workflow
break;
case 'ring_group':
$step_twiml = self::create_ring_group_twiml($step);
$stop_after_step = true; // Ring group ends the workflow
break;
case 'voicemail':
// 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':
$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':
self::send_sms_notification($step, $call_data);
continue 2;
default:
continue 2;
}
// Add step TwiML to combined response
if ($step_twiml) {
error_log('TWP Workflow: Appending step TwiML to combined response');
error_log('TWP Workflow: Step TwiML before append: ' . $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;
error_log('TWP Workflow: Combined response after append: ' . $response->asXML());
} else {
error_log('TWP Workflow: ERROR - Failed to parse step TwiML: ' . $step_twiml);
}
// Stop processing if this step type should end the workflow
if ($stop_after_step) {
error_log('TWP Workflow: Stopping after this step (stop_after_step = true)');
break;
}
}
}
// Return combined response or default
if ($has_response) {
$final_twiml = $response->asXML();
error_log('TWP Workflow: Final workflow TwiML response: ' . $final_twiml);
return $final_twiml;
}
// Default response
error_log('TWP Workflow: No response generated, returning 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':
// Create dial instance
$dial = $response->dial('', $attributes);
// Add child Number elements
foreach ($element->children() as $child) {
$child_name = $child->getName();
if ($child_name === 'Number') {
$dial->number((string) $child, self::get_attributes($child));
} elseif ($child_name === 'Client') {
$dial->client((string) $child, self::get_attributes($child));
} elseif ($child_name === 'Queue') {
$dial->queue((string) $child, self::get_attributes($child));
} elseif ($child_name === 'Conference') {
$dial->conference((string) $child, self::get_attributes($child));
} elseif ($child_name === 'Sip') {
$dial->sip((string) $child, self::get_attributes($child));
}
}
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('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
// 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 {
$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();
}
/**
* Create IVR menu TwiML
*/
private static function create_ivr_menu_twiml($step, $elevenlabs) {
$twiml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
$gather = $twiml->addChild('Gather');
$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('/wp-json/twilio-webhook/v1/ivr-response');
if (isset($step['workflow_id'])) {
$webhook_url = add_query_arg('workflow_id', $step['workflow_id'], $webhook_url);
}
if (isset($step['id'])) {
$webhook_url = add_query_arg('step_id', $step['id'], $webhook_url);
}
$gather->addAttribute('action', $webhook_url);
}
// 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 {
$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
if (isset($step['no_input_action'])) {
switch ($step['no_input_action']) {
case 'repeat':
$redirect = $twiml->addChild('Redirect');
break;
case 'hangup':
$say = $twiml->addChild('Say', 'Goodbye');
$say->addAttribute('voice', 'alice');
$twiml->addChild('Hangup');
break;
case 'forward':
if (isset($step['forward_number'])) {
$dial = $twiml->addChild('Dial');
$dial->addAttribute('answerOnBridge', 'true');
$dial->addChild('Number', $step['forward_number']);
}
break;
}
}
return $twiml->asXML();
}
/**
* Create browser call TwiML
*/
private static function create_browser_call_twiml($step, $elevenlabs) {
$step_data = $step['data'];
$twiml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
// Get announcement message
$message = '';
if (isset($step_data['announce_message']) && !empty($step_data['announce_message'])) {
$message = $step_data['announce_message'];
} else {
$message = 'Connecting you to our agent.';
}
// Handle audio type for announcement
$audio_type = isset($step_data['audio_type']) ? $step_data['audio_type'] : 'say';
switch ($audio_type) {
case 'tts':
$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 {
$say = $twiml->addChild('Say', $message);
$say->addAttribute('voice', 'alice');
}
break;
case 'audio':
$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 {
$say = $twiml->addChild('Say', $message);
$say->addAttribute('voice', 'alice');
}
break;
default: // 'say'
$say = $twiml->addChild('Say', $message);
$say->addAttribute('voice', 'alice');
break;
}
// Dial browser clients
$dial = $twiml->addChild('Dial');
$dial->addAttribute('timeout', '30');
// Get browser client names (agents who might be online)
$browser_clients = isset($step_data['browser_clients']) ? $step_data['browser_clients'] : [];
if (empty($browser_clients)) {
// Default: try to call any available browser clients
// Get all users with agent capabilities
$agents = get_users(array(
'meta_key' => 'twp_phone_number',
'meta_compare' => 'EXISTS'
));
foreach ($agents as $agent) {
// Twilio requires alphanumeric characters only
$clean_name = preg_replace('/[^a-zA-Z0-9]/', '', $agent->display_name);
if (empty($clean_name)) {
$clean_name = 'user';
}
$client_name = 'agent' . $agent->ID . $clean_name;
$client = $dial->addChild('Client', $client_name);
}
} else {
// Call specific browser clients
foreach ($browser_clients as $client_name) {
$client = $dial->addChild('Client', $client_name);
}
}
// Add fallback if no browser clients answer
$action_url = home_url('/wp-json/twilio-webhook/v1/browser-fallback');
$dial->addAttribute('action', $action_url);
$dial->addAttribute('method', 'POST');
return $twiml->asXML();
}
/**
* Create forward TwiML
*/
private static function create_forward_twiml($step) {
error_log('TWP Workflow Forward: Creating forward TwiML with step data: ' . print_r($step, true));
$twiml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
// Check if data is nested (workflow steps have data nested)
$step_data = isset($step['data']) ? $step['data'] : $step;
// Get the forward number(s) from the proper location
$forward_numbers = array();
// Check various possible locations for forward numbers
if (isset($step_data['forward_numbers']) && is_array($step_data['forward_numbers'])) {
$forward_numbers = $step_data['forward_numbers'];
error_log('TWP Workflow Forward: Found forward_numbers array in data: ' . print_r($forward_numbers, true));
} elseif (isset($step_data['forward_number']) && !empty($step_data['forward_number'])) {
$forward_numbers = array($step_data['forward_number']);
error_log('TWP Workflow Forward: Found single forward_number in data: ' . $step_data['forward_number']);
} elseif (isset($step_data['number']) && !empty($step_data['number'])) {
$forward_numbers = array($step_data['number']);
error_log('TWP Workflow Forward: Found number in data: ' . $step_data['number']);
} elseif (isset($step['forward_numbers']) && is_array($step['forward_numbers'])) {
$forward_numbers = $step['forward_numbers'];
error_log('TWP Workflow Forward: Found forward_numbers array: ' . print_r($forward_numbers, true));
} elseif (isset($step['forward_number']) && !empty($step['forward_number'])) {
$forward_numbers = array($step['forward_number']);
error_log('TWP Workflow Forward: Found single forward_number: ' . $step['forward_number']);
} elseif (isset($step['number']) && !empty($step['number'])) {
$forward_numbers = array($step['number']);
error_log('TWP Workflow Forward: Found number: ' . $step['number']);
}
// Filter out empty numbers
$forward_numbers = array_filter($forward_numbers, function($num) {
return !empty($num);
});
if (empty($forward_numbers)) {
error_log('TWP Workflow Forward: ERROR - No forward numbers found in step data');
// Return error message instead of empty dial
$say = $twiml->addChild('Say', 'Sorry, the forwarding destination is not configured. Please try again later.');
$say->addAttribute('voice', 'alice');
$twiml->addChild('Hangup');
return $twiml->asXML();
}
error_log('TWP Workflow Forward: Forwarding to numbers: ' . implode(', ', $forward_numbers));
$dial = $twiml->addChild('Dial');
$dial->addAttribute('answerOnBridge', 'true');
// Set timeout (default to 30 seconds if not specified)
$timeout = isset($step_data['timeout']) ? $step_data['timeout'] :
(isset($step['timeout']) ? $step['timeout'] : '30');
$dial->addAttribute('timeout', $timeout);
error_log('TWP Workflow Forward: Using timeout: ' . $timeout);
// Set caller ID to the number that was called (To number from call data)
// This makes the outbound call appear to come from the number the caller dialed
$caller_id = null;
if (isset($GLOBALS['call_data']['To']) && !empty($GLOBALS['call_data']['To'])) {
$caller_id = $GLOBALS['call_data']['To'];
error_log('TWP Workflow Forward: Using incoming number as caller ID: ' . $caller_id);
} elseif (isset($_POST['To']) && !empty($_POST['To'])) {
$caller_id = $_POST['To'];
error_log('TWP Workflow Forward: Using POST To as caller ID: ' . $caller_id);
} elseif (isset($_REQUEST['To']) && !empty($_REQUEST['To'])) {
$caller_id = $_REQUEST['To'];
error_log('TWP Workflow Forward: Using REQUEST To as caller ID: ' . $caller_id);
}
if ($caller_id) {
$dial->addAttribute('callerId', $caller_id);
error_log('TWP Workflow Forward: Set callerId attribute to: ' . $caller_id);
} else {
error_log('TWP Workflow Forward: No To number found for caller ID, will use account default');
}
// Add all forward numbers
foreach ($forward_numbers as $number) {
error_log('TWP Workflow Forward: Adding number to Dial: ' . $number);
$dial->addChild('Number', $number);
}
$result = $twiml->asXML();
error_log('TWP Workflow Forward: Final Forward TwiML: ' . $result);
return $result;
}
/**
* Create queue TwiML
*/
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('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
// Check if data is nested (workflow steps have data nested)
$step_data = isset($step['data']) ? $step['data'] : $step;
// Get the actual queue name from the database if we have a queue_id
$queue_name = '';
$queue_id = null;
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 {
error_log('TWP Workflow: No queue_id or queue_name in step data');
}
// 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;
}
/**
* Create ring group TwiML
*/
private static function create_ring_group_twiml($step) {
$twiml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
if (isset($step['announce_message'])) {
$say = $twiml->addChild('Say', $step['announce_message']);
$say->addAttribute('voice', 'alice');
}
// Get group phone numbers
$group_id = intval($step['group_id']);
$phone_numbers = TWP_Agent_Groups::get_group_phone_numbers($group_id);
if (empty($phone_numbers)) {
$say = $twiml->addChild('Say', 'No agents are available in this group. Please try again later.');
$say->addAttribute('voice', 'alice');
$twiml->addChild('Hangup');
return $twiml->asXML();
}
$dial = $twiml->addChild('Dial');
$dial->addAttribute('answerOnBridge', 'true');
if (isset($step['timeout'])) {
$dial->addAttribute('timeout', $step['timeout']);
} else {
$dial->addAttribute('timeout', '30');
}
// Set caller ID - use provided value or default to the incoming number
if (isset($step['caller_id']) && !empty($step['caller_id'])) {
$dial->addAttribute('callerId', $step['caller_id']);
} else {
// Use the number that was called (To number) as default caller ID
$caller_id = null;
if (isset($GLOBALS['call_data']['To']) && !empty($GLOBALS['call_data']['To'])) {
$caller_id = $GLOBALS['call_data']['To'];
} elseif (isset($_POST['To']) && !empty($_POST['To'])) {
$caller_id = $_POST['To'];
} elseif (isset($_REQUEST['To']) && !empty($_REQUEST['To'])) {
$caller_id = $_REQUEST['To'];
}
if ($caller_id) {
$dial->addAttribute('callerId', $caller_id);
}
}
// Set action URL to handle no-answer scenarios
$action_url = home_url('/wp-json/twilio-webhook/v1/ring-group-result?' . http_build_query([
'group_id' => $group_id,
'queue_name' => isset($step['queue_name']) ? $step['queue_name'] : null,
'fallback_action' => isset($step['fallback_action']) ? $step['fallback_action'] : 'queue'
]));
$dial->addAttribute('action', $action_url);
// Add all group numbers for simultaneous ring
foreach ($phone_numbers as $number) {
if (!empty($number)) {
$dial->addChild('Number', $number);
}
}
return $twiml->asXML();
}
/**
* Create voicemail TwiML
*/
public static function create_voicemail_twiml($step, $elevenlabs) {
$twiml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
// 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');
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('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');
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');
// 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;
}
/**
* Handle schedule check
*/
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;
}
// 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 ($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 {
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('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
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
*/
private static function execute_action($action, $call_data) {
switch ($action['type']) {
case 'forward':
return self::create_forward_twiml($action);
case 'voicemail':
$elevenlabs = new TWP_ElevenLabs_API();
return self::create_voicemail_twiml($action, $elevenlabs);
case 'queue':
$elevenlabs = new TWP_ElevenLabs_API();
return self::create_queue_twiml($action, $elevenlabs);
case 'ring_group':
return self::create_ring_group_twiml($action);
case 'message':
$twiml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
$say = $twiml->addChild('Say', $action['message']);
$say->addAttribute('voice', 'alice');
return $twiml->asXML();
default:
return false;
}
}
/**
* Check conditions
*/
private static function check_conditions($conditions, $call_data) {
foreach ($conditions as $condition) {
switch ($condition['type']) {
case 'time':
$current_time = current_time('H:i');
if ($current_time < $condition['start_time'] || $current_time > $condition['end_time']) {
return false;
}
break;
case 'day_of_week':
$current_day = strtolower(date('l'));
if (!in_array($current_day, $condition['days'])) {
return false;
}
break;
case 'caller_id':
if (!in_array($call_data['From'], $condition['numbers'])) {
return false;
}
break;
}
}
return true;
}
/**
* Send SMS notification
*/
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, $from_number);
}
/**
* Create default response
*/
private static function create_default_response() {
$twiml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
$say = $twiml->addChild('Say', 'Thank you for calling. Goodbye.');
$say->addAttribute('voice', 'alice');
$twiml->addChild('Hangup');
return $twiml->asXML();
}
/**
* Get workflow
*/
public static function get_workflow($workflow_id) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_workflows';
return $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table_name WHERE id = %d",
$workflow_id
));
}
/**
* Get workflows
*/
public static function get_workflows() {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_workflows';
return $wpdb->get_results("SELECT * FROM $table_name ORDER BY created_at DESC");
}
/**
* Get workflow by phone number
* Now checks both the legacy phone_number field and the new junction table
*/
public static function get_workflow_by_phone_number($phone_number) {
global $wpdb;
$workflows_table = $wpdb->prefix . 'twp_workflows';
$phones_table = $wpdb->prefix . 'twp_workflow_phones';
// First check the new junction table
$workflow = $wpdb->get_row($wpdb->prepare(
"SELECT w.* FROM $workflows_table w
INNER JOIN $phones_table p ON w.id = p.workflow_id
WHERE p.phone_number = %s AND w.is_active = 1
ORDER BY w.created_at DESC LIMIT 1",
$phone_number
));
// If not found, fall back to legacy phone_number field
if (!$workflow) {
$workflow = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $workflows_table WHERE phone_number = %s AND is_active = 1 ORDER BY created_at DESC LIMIT 1",
$phone_number
));
}
return $workflow;
}
/**
* Update workflow
*/
public static function update_workflow($workflow_id, $data) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_workflows';
$update_data = array();
$update_format = array();
if (isset($data['workflow_name'])) {
$update_data['workflow_name'] = sanitize_text_field($data['workflow_name']);
$update_format[] = '%s';
}
if (isset($data['phone_number'])) {
$update_data['phone_number'] = sanitize_text_field($data['phone_number']);
$update_format[] = '%s';
}
if (isset($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';
}
if (isset($data['is_active'])) {
$update_data['is_active'] = $data['is_active'] ? 1 : 0;
$update_format[] = '%d';
}
return $wpdb->update(
$table_name,
$update_data,
array('id' => $workflow_id),
$update_format,
array('%d')
);
}
/**
* Delete workflow
*/
public static function delete_workflow($workflow_id) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_workflows';
$phones_table = $wpdb->prefix . 'twp_workflow_phones';
// Delete phone numbers first
$wpdb->delete($phones_table, array('workflow_id' => $workflow_id), array('%d'));
// Then delete workflow
return $wpdb->delete(
$table_name,
array('id' => $workflow_id),
array('%d')
);
}
/**
* Set phone numbers for a workflow
*/
public static function set_workflow_phone_numbers($workflow_id, $phone_numbers) {
global $wpdb;
$phones_table = $wpdb->prefix . 'twp_workflow_phones';
// Remove existing phone numbers for this workflow
$wpdb->delete($phones_table, array('workflow_id' => $workflow_id));
// Add new phone numbers
foreach ($phone_numbers as $phone_number) {
if (!empty($phone_number)) {
$wpdb->insert(
$phones_table,
array(
'workflow_id' => $workflow_id,
'phone_number' => $phone_number
)
);
}
}
return true;
}
/**
* Get phone numbers for a workflow
*/
public static function get_workflow_phone_numbers($workflow_id) {
global $wpdb;
$phones_table = $wpdb->prefix . 'twp_workflow_phones';
$numbers = $wpdb->get_col($wpdb->prepare(
"SELECT phone_number FROM $phones_table WHERE workflow_id = %d",
$workflow_id
));
// If no numbers in junction table, check legacy field
if (empty($numbers)) {
$workflows_table = $wpdb->prefix . 'twp_workflows';
$legacy_number = $wpdb->get_var($wpdb->prepare(
"SELECT phone_number FROM $workflows_table WHERE id = %d",
$workflow_id
));
if ($legacy_number) {
$numbers = array($legacy_number);
// Optionally migrate to new structure
self::set_workflow_phone_numbers($workflow_id, $numbers);
}
}
return $numbers;
}
}