progress made

This commit is contained in:
2025-08-11 20:31:48 -07:00
parent 805af2f199
commit 304b5de40b
15 changed files with 4028 additions and 404 deletions

View File

@@ -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', '');
}
}

View File

@@ -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;

View File

@@ -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}");
}
}
}
}

View File

@@ -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);
}
/**

View File

@@ -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");
}
}
}

View File

@@ -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;
}

View File

@@ -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);
}
/**

View File

@@ -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()
];
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
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('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
$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('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
$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('<?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');
}
// 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('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
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('<?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
*/
@@ -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';
}